OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
HASH      025a976f2e76
DATE      2026-07-17
SUBJECT   web: gauge, sparkline, keyvalue, banner, steps, calendar, pagination, kbd,
          switch, stepper, and dialog
FILES     36 CHANGED
HASH      025a976f2e76
DATE      2026-07-17
SUBJECT   web: gauge, sparkline, keyvalue,
          banner, steps, calendar, pagination,
          kbd, switch, stepper, and dialog
FILES     36 CHANGED
 

diff --git a/ooknet-design/src/components/Banner/Banner.astro b/ooknet-design/src/
components/Banner/Banner.astro
new file mode 100644
index 0000000..1fdc698
--- /dev/null
+++ b/ooknet-design/src/components/Banner/Banner.astro
@@ -0,0 +1,11 @@
+---
+import Frame from "../Frame/Frame.astro";
+import { buildBanner, BANNER_LABEL, type Props } from "./Banner";
+import "./Banner.scss";
+
+const { variant = "notice", message } = Astro.props as Props;
+---
+<div class="banner" role={variant === "notice" ? "note" : "alert"}>
+  <Frame class={`banner-frame banner-${variant}`} build={(w) => buildBanner(varia
nt, message, w)} />
+  <span class="sr-only">{BANNER_LABEL[variant]}: {message}</span>
+</div>

diff --git a/ooknet-design/src/components/Ba
nner/Banner.astro b/ooknet-design/src/compon
ents/Banner/Banner.astro
new file mode 100644
index 0000000..1fdc698
--- /dev/null
+++ b/ooknet-design/src/components/Banner/Ba
nner.astro
@@ -0,0 +1,11 @@
+---
+import Frame from "../Frame/Frame.astro";
+import { buildBanner, BANNER_LABEL, type Pr
ops } from "./Banner";
+import "./Banner.scss";
+
+const { variant = "notice", message } = Ast
ro.props as Props;
+---
+<div class="banner" role={variant === "noti
ce" ? "note" : "alert"}>
+  <Frame class={`banner-frame banner-${vari
ant}`} build={(w) => buildBanner(variant, me
ssage, w)} />
+  <span class="sr-only">{BANNER_LABEL[varia
nt]}: {message}</span>
+</div>
 

diff --git a/ooknet-design/src/components/Banner/Banner.scss b/ooknet-design/src/c
omponents/Banner/Banner.scss
new file mode 100644
index 0000000..b3f0be3
--- /dev/null
+++ b/ooknet-design/src/components/Banner/Banner.scss
@@ -0,0 +1,12 @@
+.banner {
+  margin: 0.8em 0;
+
+  pre.ascii.banner-frame {
+    // The sr-only line carries the content for screen readers.
+    user-select: text;
+  }
+
+  pre.ascii.banner-error {
+    font-weight: 700;
+  }
+}

diff --git a/ooknet-design/src/components/Ba
nner/Banner.scss b/ooknet-design/src/compone
nts/Banner/Banner.scss
new file mode 100644
index 0000000..b3f0be3
--- /dev/null
+++ b/ooknet-design/src/components/Banner/Ba
nner.scss
@@ -0,0 +1,12 @@
+.banner {
+  margin: 0.8em 0;
+
+  pre.ascii.banner-frame {
+    // The sr-only line carries the content
 for screen readers.
+    user-select: text;
+  }
+
+  pre.ascii.banner-error {
+    font-weight: 700;
+  }
+}
 

diff --git a/ooknet-design/src/components/Banner/Banner.test.ts b/ooknet-design/sr
c/components/Banner/Banner.test.ts
new file mode 100644
index 0000000..5156618
--- /dev/null
+++ b/ooknet-design/src/components/Banner/Banner.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest";
+import { buildBanner } from "./Banner";
+import { len } from "../../lib/ascii";
+
+describe("buildBanner", () => {
+  it("renders every line at the frame width", () => {
+    for (const w of [48, 86]) {
+      const out = buildBanner("warning", "frequency drift detected on channel bra
vo", w);
+      for (const ln of out.split("\n")) expect(len(ln)).toBe(w);
+    }
+  });
+
+  it("embeds the variant label in the top rule", () => {
+    expect(buildBanner("error", "x", 48).split("\n")[0]).toContain("─ ERROR ─");
+  });
+
+  it("wraps long messages", () => {
+    const out = buildBanner("notice", "word ".repeat(30).trim(), 48);
+    expect(out.split("\n").length).toBeGreaterThan(4);
+  });
+});

diff --git a/ooknet-design/src/components/Ba
nner/Banner.test.ts b/ooknet-design/src/comp
onents/Banner/Banner.test.ts
new file mode 100644
index 0000000..5156618
--- /dev/null
+++ b/ooknet-design/src/components/Banner/Ba
nner.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vites
t";
+import { buildBanner } from "./Banner";
+import { len } from "../../lib/ascii";
+
+describe("buildBanner", () => {
+  it("renders every line at the frame width
", () => {
+    for (const w of [48, 86]) {
+      const out = buildBanner("warning", "f
requency drift detected on channel bravo", w
);
+      for (const ln of out.split("\n")) exp
ect(len(ln)).toBe(w);
+    }
+  });
+
+  it("embeds the variant label in the top r
ule", () => {
+    expect(buildBanner("error", "x", 48).sp
lit("\n")[0]).toContain("─ ERROR ─");
+  });
+
+  it("wraps long messages", () => {
+    const out = buildBanner("notice", "word
 ".repeat(30).trim(), 48);
+    expect(out.split("\n").length).toBeGrea
terThan(4);
+  });
+});
 

diff --git a/ooknet-design/src/components/Banner/Banner.ts b/ooknet-design/src/com
ponents/Banner/Banner.ts
new file mode 100644
index 0000000..889af4b
--- /dev/null
+++ b/ooknet-design/src/components/Banner/Banner.ts
@@ -0,0 +1,27 @@
+// Page-level notice in callout chrome - the component sibling of the
+// markdown [!NOTE] transform, for use outside prose.
+import { G, checkGrid, len, pad, wrap } from "../../lib/ascii";
+
+export type BannerVariant = "notice" | "warning" | "error";
+
+export interface Props {
+  variant?: BannerVariant;
+  message: string;
+}
+
+export const BANNER_LABEL: Record<BannerVariant, string> = {
+  notice: "NOTICE",
+  warning: "WARNING",
+  error: "ERROR",
+};
+
+export function buildBanner(variant: BannerVariant, message: string, width: numbe
r): string {
+  const label = BANNER_LABEL[variant];
+  const inner = width - 4;
+  const lines = [
+    `${G.TL}${G.H} ${label} ${G.H.repeat(Math.max(0, width - 5 - len(label)))}${G
.TR}`,
+    ...wrap(message, inner).split("\n").map((ln) => `${G.V} ${pad(ln, inner)} ${G
.V}`),
+    `${G.BL}${G.H.repeat(width - 2)}${G.BR}`,
+  ];
+  return checkGrid(lines.join("\n"), width, `banner(${label})`);
+}

diff --git a/ooknet-design/src/components/Ba
nner/Banner.ts b/ooknet-design/src/component
s/Banner/Banner.ts
new file mode 100644
index 0000000..889af4b
--- /dev/null
+++ b/ooknet-design/src/components/Banner/Ba
nner.ts
@@ -0,0 +1,27 @@
+// Page-level notice in callout chrome - th
e component sibling of the
+// markdown [!NOTE] transform, for use outs
ide prose.
+import { G, checkGrid, len, pad, wrap } fro
m "../../lib/ascii";
+
+export type BannerVariant = "notice" | "war
ning" | "error";
+
+export interface Props {
+  variant?: BannerVariant;
+  message: string;
+}
+
+export const BANNER_LABEL: Record<BannerVar
iant, string> = {
+  notice: "NOTICE",
+  warning: "WARNING",
+  error: "ERROR",
+};
+
+export function buildBanner(variant: Banner
Variant, message: string, width: number): st
ring {
+  const label = BANNER_LABEL[variant];
+  const inner = width - 4;
+  const lines = [
+    `${G.TL}${G.H} ${label} ${G.H.repeat(Ma
th.max(0, width - 5 - len(label)))}${G.TR}`,
+    ...wrap(message, inner).split("\n").map
((ln) => `${G.V} ${pad(ln, inner)} ${G.V}`),
+    `${G.BL}${G.H.repeat(width - 2)}${G.BR}
`,
+  ];
+  return checkGrid(lines.join("\n"), width,
 `banner(${label})`);
+}
 

diff --git a/ooknet-design/src/components/Calendar/Calendar.astro b/ooknet-design/
src/components/Calendar/Calendar.astro
new file mode 100644
index 0000000..dd8f915
--- /dev/null
+++ b/ooknet-design/src/components/Calendar/Calendar.astro
@@ -0,0 +1,7 @@
+---
+import Pre from "../Pre/Pre.astro";
+import { buildCalendar, type Props } from "./Calendar";
+
+const { year, month, marks } = Astro.props as Props;
+---
+<Pre class="calendar">{buildCalendar(year, month, marks)}</Pre>

diff --git a/ooknet-design/src/components/Ca
lendar/Calendar.astro b/ooknet-design/src/co
mponents/Calendar/Calendar.astro
new file mode 100644
index 0000000..dd8f915
--- /dev/null
+++ b/ooknet-design/src/components/Calendar/
Calendar.astro
@@ -0,0 +1,7 @@
+---
+import Pre from "../Pre/Pre.astro";
+import { buildCalendar, type Props } from "
./Calendar";
+
+const { year, month, marks } = Astro.props 
as Props;
+---
+<Pre class="calendar">{buildCalendar(year, 
month, marks)}</Pre>
 

diff --git a/ooknet-design/src/components/Calendar/Calendar.test.ts b/ooknet-desig
n/src/components/Calendar/Calendar.test.ts
new file mode 100644
index 0000000..d185992
--- /dev/null
+++ b/ooknet-design/src/components/Calendar/Calendar.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import { buildCalendar, CAL_W } from "./Calendar";
+import { len } from "../../lib/ascii";
+
+describe("buildCalendar", () => {
+  it("lays out July 2026 starting on Wednesday", () => {
+    const lines = buildCalendar(2026, 7).split("\n");
+    expect(lines[0].trim()).toBe("JULY 2026");
+    expect(lines[1]).toBe("MO TU WE TH FR SA SU");
+    expect(lines[2]).toBe("       1  2  3  4  5");
+    expect(lines.at(-1)).toBe("27 28 29 30 31");
+  });
+
+  it("never exceeds the calendar width", () => {
+    for (const m of [1, 2, 6, 12]) {
+      for (const ln of buildCalendar(2026, m).split("\n")) {
+        expect(len(ln)).toBeLessThanOrEqual(CAL_W);
+      }
+    }
+  });
+
+  it("marks days with a * in the separator column", () => {
+    const out = buildCalendar(2026, 7, [17]);
+    expect(out).toContain("*17");
+    // grid alignment unchanged: the 16 stays in place
+    const line = out.split("\n").find((l) => l.includes("17"))!;
+    expect(line.indexOf("17")).toBe(line.indexOf("16") + 3);
+  });
+
+  it("handles a February that starts on Monday", () => {
+    const lines = buildCalendar(2027, 2).split("\n");
+    expect(lines[2]).toBe(" 1  2  3  4  5  6  7");
+  });
+});

diff --git a/ooknet-design/src/components/Ca
lendar/Calendar.test.ts b/ooknet-design/src/
components/Calendar/Calendar.test.ts
new file mode 100644
index 0000000..d185992
--- /dev/null
+++ b/ooknet-design/src/components/Calendar/
Calendar.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vites
t";
+import { buildCalendar, CAL_W } from "./Cal
endar";
+import { len } from "../../lib/ascii";
+
+describe("buildCalendar", () => {
+  it("lays out July 2026 starting on Wednes
day", () => {
+    const lines = buildCalendar(2026, 7).sp
lit("\n");
+    expect(lines[0].trim()).toBe("JULY 2026
");
+    expect(lines[1]).toBe("MO TU WE TH FR S
A SU");
+    expect(lines[2]).toBe("       1  2  3  
4  5");
+    expect(lines.at(-1)).toBe("27 28 29 30 
31");
+  });
+
+  it("never exceeds the calendar width", ()
 => {
+    for (const m of [1, 2, 6, 12]) {
+      for (const ln of buildCalendar(2026, 
m).split("\n")) {
+        expect(len(ln)).toBeLessThanOrEqual
(CAL_W);
+      }
+    }
+  });
+
+  it("marks days with a * in the separator 
column", () => {
+    const out = buildCalendar(2026, 7, [17]
);
+    expect(out).toContain("*17");
+    // grid alignment unchanged: the 16 sta
ys in place
+    const line = out.split("\n").find((l) =
> l.includes("17"))!;
+    expect(line.indexOf("17")).toBe(line.in
dexOf("16") + 3);
+  });
+
+  it("handles a February that starts on Mon
day", () => {
+    const lines = buildCalendar(2027, 2).sp
lit("\n");
+    expect(lines[2]).toBe(" 1  2  3  4  5  
6  7");
+  });
+});
 

diff --git a/ooknet-design/src/components/Calendar/Calendar.ts b/ooknet-design/src
/components/Calendar/Calendar.ts
new file mode 100644
index 0000000..6e4906e
--- /dev/null
+++ b/ooknet-design/src/components/Calendar/Calendar.ts
@@ -0,0 +1,53 @@
+// Classic cal(1) month grid, Monday-first, 20 cells wide. Marked days
+// get a * prefix in the separator column so the grid never shifts.
+import { center, checkGrid } from "../../lib/ascii";
+
+export interface Props {
+  year: number;
+  /** 1-12. */
+  month: number;
+  marks?: number[];
+}
+
+export const CAL_W = 20;
+
+const MONTHS = [
+  "JANUARY", "FEBRUARY", "MARCH", "APRIL", "MAY", "JUNE",
+  "JULY", "AUGUST", "SEPTEMBER", "OCTOBER", "NOVEMBER", "DECEMBER",
+];
+
+export function buildCalendar(year: number, month: number, marks: number[] = []):
 string {
+  const marked = new Set(marks);
+  // Monday-first offset of the 1st.
+  const offset = (new Date(Date.UTC(year, month - 1, 1)).getUTCDay() + 6) % 7;
+  const days = new Date(Date.UTC(year, month, 0)).getUTCDate();
+
+  const lines = [center(`${MONTHS[month - 1]} ${year}`, CAL_W), "MO TU WE TH FR S
A SU"];
+
+  let week: string[] = Array.from({ length: offset }, () => "  ");
+  for (let d = 1; d <= days; d++) {
+    week.push(String(d).padStart(2));
+    if (week.length === 7) {
+      lines.push(weekLine(week, marked));
+      week = [];
+    }
+  }
+  if (week.length) {
+    while (week.length < 7) week.push("  ");
+    lines.push(weekLine(week, marked));
+  }
+
+  return checkGrid(lines.join("\n"), CAL_W, "calendar");
+}
+
+function weekLine(cells: string[], marked: Set<number>): string {
+  let out = "";
+  cells.forEach((c, i) => {
+    const day = parseInt(c, 10);
+    out += (i === 0 ? "" : marked.has(day) ? "*" : " ") + c;
+  });
+  // A marked Monday has no separator column; flag it inside the cell.
+  const first = parseInt(cells[0], 10);
+  if (marked.has(first) && first < 10) out = "*" + out.slice(1);
+  return out.trimEnd();
+}

diff --git a/ooknet-design/src/components/Ca
lendar/Calendar.ts b/ooknet-design/src/compo
nents/Calendar/Calendar.ts
new file mode 100644
index 0000000..6e4906e
--- /dev/null
+++ b/ooknet-design/src/components/Calendar/
Calendar.ts
@@ -0,0 +1,53 @@
+// Classic cal(1) month grid, Monday-first,
 20 cells wide. Marked days
+// get a * prefix in the separator column s
o the grid never shifts.
+import { center, checkGrid } from "../../li
b/ascii";
+
+export interface Props {
+  year: number;
+  /** 1-12. */
+  month: number;
+  marks?: number[];
+}
+
+export const CAL_W = 20;
+
+const MONTHS = [
+  "JANUARY", "FEBRUARY", "MARCH", "APRIL", 
"MAY", "JUNE",
+  "JULY", "AUGUST", "SEPTEMBER", "OCTOBER",
 "NOVEMBER", "DECEMBER",
+];
+
+export function buildCalendar(year: number,
 month: number, marks: number[] = []): strin
g {
+  const marked = new Set(marks);
+  // Monday-first offset of the 1st.
+  const offset = (new Date(Date.UTC(year, m
onth - 1, 1)).getUTCDay() + 6) % 7;
+  const days = new Date(Date.UTC(year, mont
h, 0)).getUTCDate();
+
+  const lines = [center(`${MONTHS[month - 1
]} ${year}`, CAL_W), "MO TU WE TH FR SA SU"]
;
+
+  let week: string[] = Array.from({ length:
 offset }, () => "  ");
+  for (let d = 1; d <= days; d++) {
+    week.push(String(d).padStart(2));
+    if (week.length === 7) {
+      lines.push(weekLine(week, marked));
+      week = [];
+    }
+  }
+  if (week.length) {
+    while (week.length < 7) week.push("  ")
;
+    lines.push(weekLine(week, marked));
+  }
+
+  return checkGrid(lines.join("\n"), CAL_W,
 "calendar");
+}
+
+function weekLine(cells: string[], marked: 
Set<number>): string {
+  let out = "";
+  cells.forEach((c, i) => {
+    const day = parseInt(c, 10);
+    out += (i === 0 ? "" : marked.has(day) 
? "*" : " ") + c;
+  });
+  // A marked Monday has no separator colum
n; flag it inside the cell.
+  const first = parseInt(cells[0], 10);
+  if (marked.has(first) && first < 10) out 
= "*" + out.slice(1);
+  return out.trimEnd();
+}
 

diff --git a/ooknet-design/src/components/Dialog/Dialog.astro b/ooknet-design/src/
components/Dialog/Dialog.astro
new file mode 100644
index 0000000..16b116e
--- /dev/null
+++ b/ooknet-design/src/components/Dialog/Dialog.astro
@@ -0,0 +1,35 @@
+---
+// Native <dialog> in a double-line frame. Renders once at the mobile
+// width so it fits any viewport. Open with any element carrying
+// data-dialog-open="<id>"; [ CLOSE ] and Esc dismiss it.
+import Pre from "../Pre/Pre.astro";
+import Button from "../Button/Button.astro";
+import { MOBILE_FRAME_W } from "../../lib/config";
+import { buildDialogTop, buildDialogBottom, type Props } from "./Dialog";
+import "./Dialog.scss";
+
+const { id, title, width = MOBILE_FRAME_W } = Astro.props as Props;
+---
+<dialog class="dialog" id={id} aria-label={title}>
+  <Pre>{buildDialogTop(title, width)}</Pre>
+  <div class="dialog-body">
+    <slot />
+  </div>
+  <div class="dialog-footer"><span data-dialog-close><Button label="CLOSE" /></sp
an></div>
+  <Pre>{buildDialogBottom(width)}</Pre>
+</dialog>
+
+<script>
+  document.addEventListener("click", (e) => {
+    const target = e.target as HTMLElement;
+
+    const opener = target.closest<HTMLElement>("[data-dialog-open]");
+    if (opener) {
+      document.querySelector<HTMLDialogElement>(`#${opener.dataset.dialogOpen}`)?
.showModal();
+      return;
+    }
+
+    const closer = target.closest<HTMLElement>("[data-dialog-close]");
+    if (closer) closer.closest("dialog")?.close();
+  });
+</script>

diff --git a/ooknet-design/src/components/Di
alog/Dialog.astro b/ooknet-design/src/compon
ents/Dialog/Dialog.astro
new file mode 100644
index 0000000..16b116e
--- /dev/null
+++ b/ooknet-design/src/components/Dialog/Di
alog.astro
@@ -0,0 +1,35 @@
+---
+// Native <dialog> in a double-line frame. 
Renders once at the mobile
+// width so it fits any viewport. Open with
 any element carrying
+// data-dialog-open="<id>"; [ CLOSE ] and E
sc dismiss it.
+import Pre from "../Pre/Pre.astro";
+import Button from "../Button/Button.astro"
;
+import { MOBILE_FRAME_W } from "../../lib/c
onfig";
+import { buildDialogTop, buildDialogBottom,
 type Props } from "./Dialog";
+import "./Dialog.scss";
+
+const { id, title, width = MOBILE_FRAME_W }
 = Astro.props as Props;
+---
+<dialog class="dialog" id={id} aria-label={
title}>
+  <Pre>{buildDialogTop(title, width)}</Pre>
+  <div class="dialog-body">
+    <slot />
+  </div>
+  <div class="dialog-footer"><span data-dia
log-close><Button label="CLOSE" /></span></d
iv>
+  <Pre>{buildDialogBottom(width)}</Pre>
+</dialog>
+
+<script>
+  document.addEventListener("click", (e) =>
 {
+    const target = e.target as HTMLElement;
+
+    const opener = target.closest<HTMLEleme
nt>("[data-dialog-open]");
+    if (opener) {
+      document.querySelector<HTMLDialogElem
ent>(`#${opener.dataset.dialogOpen}`)?.showM
odal();
+      return;
+    }
+
+    const closer = target.closest<HTMLEleme
nt>("[data-dialog-close]");
+    if (closer) closer.closest("dialog")?.c
lose();
+  });
+</script>
 

diff --git a/ooknet-design/src/components/Dialog/Dialog.scss b/ooknet-design/src/c
omponents/Dialog/Dialog.scss
new file mode 100644
index 0000000..edc8ebb
--- /dev/null
+++ b/ooknet-design/src/components/Dialog/Dialog.scss
@@ -0,0 +1,48 @@
+.dialog {
+  border: 0;
+  padding: 0;
+  background: var(--paper);
+  color: var(--ink);
+  font-family: var(--mono);
+  font-size: var(--fs);
+  line-height: var(--lh);
+
+  // pre.ascii is frame-width by default; the dialog is narrower.
+  pre.ascii {
+    width: auto;
+  }
+
+  &::backdrop {
+    background: rgba(0, 0, 0, 0.45);
+  }
+
+  // ║ side rails, same glyph-column trick as callouts.
+  .dialog-body,
+  .dialog-footer {
+    position: relative;
+    padding: 0 calc(var(--cell-w) * 3);
+
+    &::before,
+    &::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;
+      top: 0;
+      bottom: 0;
+      font-family: var(--mono-frame);
+      white-space: pre;
+      overflow: hidden;
+      color: var(--ink);
+    }
+    &::before { left: 0; }
+    &::after { right: 0; }
+  }
+
+  .dialog-body {
+    margin: 0.4em 0;
+  }
+
+  .dialog-footer {
+    padding-top: 0.2em;
+    padding-bottom: 0.4em;
+  }
+}

diff --git a/ooknet-design/src/components/Di
alog/Dialog.scss b/ooknet-design/src/compone
nts/Dialog/Dialog.scss
new file mode 100644
index 0000000..edc8ebb
--- /dev/null
+++ b/ooknet-design/src/components/Dialog/Di
alog.scss
@@ -0,0 +1,48 @@
+.dialog {
+  border: 0;
+  padding: 0;
+  background: var(--paper);
+  color: var(--ink);
+  font-family: var(--mono);
+  font-size: var(--fs);
+  line-height: var(--lh);
+
+  // pre.ascii is frame-width by default; t
he dialog is narrower.
+  pre.ascii {
+    width: auto;
+  }
+
+  &::backdrop {
+    background: rgba(0, 0, 0, 0.45);
+  }
+
+  // ║ side rails, same glyph-column trick 
as callouts.
+  .dialog-body,
+  .dialog-footer {
+    position: relative;
+    padding: 0 calc(var(--cell-w) * 3);
+
+    &::before,
+    &::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;
+      top: 0;
+      bottom: 0;
+      font-family: var(--mono-frame);
+      white-space: pre;
+      overflow: hidden;
+      color: var(--ink);
+    }
+    &::before { left: 0; }
+    &::after { right: 0; }
+  }
+
+  .dialog-body {
+    margin: 0.4em 0;
+  }
+
+  .dialog-footer {
+    padding-top: 0.2em;
+    padding-bottom: 0.4em;
+  }
+}
 

diff --git a/ooknet-design/src/components/Dialog/Dialog.ts b/ooknet-design/src/com
ponents/Dialog/Dialog.ts
new file mode 100644
index 0000000..a430085
--- /dev/null
+++ b/ooknet-design/src/components/Dialog/Dialog.ts
@@ -0,0 +1,23 @@
+// Double-line frame rules for the modal - ═ distinguishes the overlay
+// from single-ruled page content.
+import { checkGrid, len } from "../../lib/ascii";
+
+export interface Props {
+  id: string;
+  title: string;
+  /** Frame width in cells; defaults to the mobile frame so it fits any
+   *  viewport. */
+  width?: number;
+}
+
+export function buildDialogTop(title: string, width: number): string {
+  return checkGrid(
+    `╔═ ${title} ${"═".repeat(Math.max(0, width - 5 - len(title)))}╗`,
+    width,
+    `dialog(${title})`,
+  );
+}
+
+export function buildDialogBottom(width: number): string {
+  return `╚${"═".repeat(width - 2)}╝`;
+}

diff --git a/ooknet-design/src/components/Di
alog/Dialog.ts b/ooknet-design/src/component
s/Dialog/Dialog.ts
new file mode 100644
index 0000000..a430085
--- /dev/null
+++ b/ooknet-design/src/components/Dialog/Di
alog.ts
@@ -0,0 +1,23 @@
+// Double-line frame rules for the modal - 
═ distinguishes the overlay
+// from single-ruled page content.
+import { checkGrid, len } from "../../lib/a
scii";
+
+export interface Props {
+  id: string;
+  title: string;
+  /** Frame width in cells; defaults to the
 mobile frame so it fits any
+   *  viewport. */
+  width?: number;
+}
+
+export function buildDialogTop(title: strin
g, width: number): string {
+  return checkGrid(
+    `╔═ ${title} ${"═".repeat(Math.max(0, w
idth - 5 - len(title)))}╗`,
+    width,
+    `dialog(${title})`,
+  );
+}
+
+export function buildDialogBottom(width: nu
mber): string {
+  return `╚${"═".repeat(width - 2)}╝`;
+}
 

diff --git a/ooknet-design/src/components/Gauge/Gauge.astro b/ooknet-design/src/co
mponents/Gauge/Gauge.astro
new file mode 100644
index 0000000..a0519c8
--- /dev/null
+++ b/ooknet-design/src/components/Gauge/Gauge.astro
@@ -0,0 +1,6 @@
+---
+import { buildGauge, type Props } from "./Gauge";
+
+const { value, threshold, cells, low, high } = Astro.props as Props;
+---
+<span class="gauge" role="meter" aria-valuenow={Math.round(value)} aria-valuemin=
"0" aria-valuemax="100">{buildGauge(value, { cells, threshold, low, high })}</span
>

diff --git a/ooknet-design/src/components/Ga
uge/Gauge.astro b/ooknet-design/src/componen
ts/Gauge/Gauge.astro
new file mode 100644
index 0000000..a0519c8
--- /dev/null
+++ b/ooknet-design/src/components/Gauge/Gau
ge.astro
@@ -0,0 +1,6 @@
+---
+import { buildGauge, type Props } from "./G
auge";
+
+const { value, threshold, cells, low, high 
} = Astro.props as Props;
+---
+<span class="gauge" role="meter" aria-value
now={Math.round(value)} aria-valuemin="0" ar
ia-valuemax="100">{buildGauge(value, { cells
, threshold, low, high })}</span>
 

diff --git a/ooknet-design/src/components/Gauge/Gauge.test.ts b/ooknet-design/src/
components/Gauge/Gauge.test.ts
new file mode 100644
index 0000000..67001d5
--- /dev/null
+++ b/ooknet-design/src/components/Gauge/Gauge.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+import { buildGauge, gaugeWidth } from "./Gauge";
+import { len } from "../../lib/ascii";
+
+describe("buildGauge", () => {
+  it("renders the exact declared width", () => {
+    for (const v of [0, 47, 100]) {
+      expect(len(buildGauge(v, { cells: 10 }))).toBe(gaugeWidth(10));
+      expect(len(buildGauge(v, { cells: 10, threshold: 70 }))).toBe(gaugeWidth(10
, v >= 70 ? "CRITICAL" : "NOMINAL"));
+    }
+  });
+
+  it("places the threshold marker inside the bar", () => {
+    const g = buildGauge(30, { cells: 10, threshold: 70 });
+    expect([...g][8]).toBe("│");
+  });
+
+  it("flips the status word at the threshold", () => {
+    expect(buildGauge(47, { threshold: 70 })).toContain("NOMINAL");
+    expect(buildGauge(85, { threshold: 70 })).toContain("CRITICAL");
+  });
+
+  it("omits marker and status without a threshold", () => {
+    const g = buildGauge(47, { cells: 10 });
+    expect(g).not.toContain("│");
+    expect(g.trimEnd().endsWith("%")).toBe(true);
+  });
+});

diff --git a/ooknet-design/src/components/Ga
uge/Gauge.test.ts b/ooknet-design/src/compon
ents/Gauge/Gauge.test.ts
new file mode 100644
index 0000000..67001d5
--- /dev/null
+++ b/ooknet-design/src/components/Gauge/Gau
ge.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vites
t";
+import { buildGauge, gaugeWidth } from "./G
auge";
+import { len } from "../../lib/ascii";
+
+describe("buildGauge", () => {
+  it("renders the exact declared width", ()
 => {
+    for (const v of [0, 47, 100]) {
+      expect(len(buildGauge(v, { cells: 10 
}))).toBe(gaugeWidth(10));
+      expect(len(buildGauge(v, { cells: 10,
 threshold: 70 }))).toBe(gaugeWidth(10, v >=
 70 ? "CRITICAL" : "NOMINAL"));
+    }
+  });
+
+  it("places the threshold marker inside th
e bar", () => {
+    const g = buildGauge(30, { cells: 10, t
hreshold: 70 });
+    expect([...g][8]).toBe("│");
+  });
+
+  it("flips the status word at the threshol
d", () => {
+    expect(buildGauge(47, { threshold: 70 }
)).toContain("NOMINAL");
+    expect(buildGauge(85, { threshold: 70 }
)).toContain("CRITICAL");
+  });
+
+  it("omits marker and status without a thr
eshold", () => {
+    const g = buildGauge(47, { cells: 10 })
;
+    expect(g).not.toContain("│");
+    expect(g.trimEnd().endsWith("%")).toBe(
true);
+  });
+});
 

diff --git a/ooknet-design/src/components/Gauge/Gauge.ts b/ooknet-design/src/compo
nents/Gauge/Gauge.ts
new file mode 100644
index 0000000..af96bb2
--- /dev/null
+++ b/ooknet-design/src/components/Gauge/Gauge.ts
@@ -0,0 +1,38 @@
+// Progress bar with an optional threshold marker and status word:
+// [▓▓▓▓▓░░│░░]  47% NOMINAL
+import { len } from "../../lib/ascii";
+
+export interface Props {
+  /** 0-100. */
+  value: number;
+  /** 0-100; draws a │ marker and flips the status word above it. */
+  threshold?: number;
+  /** Bar width in cells, excluding brackets and the readout. */
+  cells?: number;
+  low?: string;
+  high?: string;
+}
+
+const clamp = (v: number) => Math.max(0, Math.min(100, Math.round(v)));
+
+export function buildGauge(
+  value: number,
+  { cells = 20, threshold, low = "NOMINAL", high = "CRITICAL" }: Omit<Props, "val
ue"> = {},
+): string {
+  const v = clamp(value);
+  const filled = Math.round((v / 100) * cells);
+  const bar = [..."▓".repeat(filled) + "░".repeat(cells - filled)];
+
+  let status = "";
+  if (threshold != null) {
+    const t = Math.min(cells - 1, Math.max(0, Math.round((clamp(threshold) / 100)
 * cells)));
+    bar[t] = "│";
+    status = " " + (v >= threshold ? high : low);
+  }
+
+  return `[${bar.join("")}] ${String(v).padStart(3)}%${status}`;
+}
+
+// "[" + cells + "] 100%" + optional " STATUS"
+export const gaugeWidth = (cells = 20, status = "") =>
+  cells + 7 + (status ? 1 + len(status) : 0);

diff --git a/ooknet-design/src/components/Ga
uge/Gauge.ts b/ooknet-design/src/components/
Gauge/Gauge.ts
new file mode 100644
index 0000000..af96bb2
--- /dev/null
+++ b/ooknet-design/src/components/Gauge/Gau
ge.ts
@@ -0,0 +1,38 @@
+// Progress bar with an optional threshold 
marker and status word:
+// [▓▓▓▓▓░░│░░]  47% NOMINAL
+import { len } from "../../lib/ascii";
+
+export interface Props {
+  /** 0-100. */
+  value: number;
+  /** 0-100; draws a │ marker and flips the
 status word above it. */
+  threshold?: number;
+  /** Bar width in cells, excluding bracket
s and the readout. */
+  cells?: number;
+  low?: string;
+  high?: string;
+}
+
+const clamp = (v: number) => Math.max(0, Ma
th.min(100, Math.round(v)));
+
+export function buildGauge(
+  value: number,
+  { cells = 20, threshold, low = "NOMINAL",
 high = "CRITICAL" }: Omit<Props, "value"> =
 {},
+): string {
+  const v = clamp(value);
+  const filled = Math.round((v / 100) * cel
ls);
+  const bar = [..."▓".repeat(filled) + "░".
repeat(cells - filled)];
+
+  let status = "";
+  if (threshold != null) {
+    const t = Math.min(cells - 1, Math.max(
0, Math.round((clamp(threshold) / 100) * cel
ls)));
+    bar[t] = "│";
+    status = " " + (v >= threshold ? high :
 low);
+  }
+
+  return `[${bar.join("")}] ${String(v).pad
Start(3)}%${status}`;
+}
+
+// "[" + cells + "] 100%" + optional " STAT
US"
+export const gaugeWidth = (cells = 20, stat
us = "") =>
+  cells + 7 + (status ? 1 + len(status) : 0
);
 

diff --git a/ooknet-design/src/components/Kbd/Kbd.astro b/ooknet-design/src/compon
ents/Kbd/Kbd.astro
new file mode 100644
index 0000000..a0e918e
--- /dev/null
+++ b/ooknet-design/src/components/Kbd/Kbd.astro
@@ -0,0 +1,10 @@
+---
+// Inline keyboard chrome: [CTRL]+[K]
+import type { Props } from "./Kbd";
+import "./Kbd.scss";
+
+const { keys } = Astro.props as Props;
+---
+<span class="kbd-combo">{keys.map((k, i) => (
+  <Fragment>{i > 0 ? "+" : ""}<kbd class="kbd">[{k.toUpperCase()}]</kbd></Fragmen
t>
+))}</span>

diff --git a/ooknet-design/src/components/Kb
d/Kbd.astro b/ooknet-design/src/components/K
bd/Kbd.astro
new file mode 100644
index 0000000..a0e918e
--- /dev/null
+++ b/ooknet-design/src/components/Kbd/Kbd.a
stro
@@ -0,0 +1,10 @@
+---
+// Inline keyboard chrome: [CTRL]+[K]
+import type { Props } from "./Kbd";
+import "./Kbd.scss";
+
+const { keys } = Astro.props as Props;
+---
+<span class="kbd-combo">{keys.map((k, i) =>
 (
+  <Fragment>{i > 0 ? "+" : ""}<kbd class="k
bd">[{k.toUpperCase()}]</kbd></Fragment>
+))}</span>
 

diff --git a/ooknet-design/src/components/Kbd/Kbd.scss b/ooknet-design/src/compone
nts/Kbd/Kbd.scss
new file mode 100644
index 0000000..651cd19
--- /dev/null
+++ b/ooknet-design/src/components/Kbd/Kbd.scss
@@ -0,0 +1,9 @@
+.kbd-combo {
+  white-space: pre;
+
+  .kbd {
+    font-family: inherit;
+    font-size: inherit;
+    font-weight: 700;
+  }
+}

diff --git a/ooknet-design/src/components/Kb
d/Kbd.scss b/ooknet-design/src/components/Kb
d/Kbd.scss
new file mode 100644
index 0000000..651cd19
--- /dev/null
+++ b/ooknet-design/src/components/Kbd/Kbd.s
css
@@ -0,0 +1,9 @@
+.kbd-combo {
+  white-space: pre;
+
+  .kbd {
+    font-family: inherit;
+    font-size: inherit;
+    font-weight: 700;
+  }
+}
 

diff --git a/ooknet-design/src/components/Kbd/Kbd.ts b/ooknet-design/src/component
s/Kbd/Kbd.ts
new file mode 100644
index 0000000..b5b16ad
--- /dev/null
+++ b/ooknet-design/src/components/Kbd/Kbd.ts
@@ -0,0 +1,3 @@
+export interface Props {
+  keys: string[];
+}

diff --git a/ooknet-design/src/components/Kb
d/Kbd.ts b/ooknet-design/src/components/Kbd/
Kbd.ts
new file mode 100644
index 0000000..b5b16ad
--- /dev/null
+++ b/ooknet-design/src/components/Kbd/Kbd.t
s
@@ -0,0 +1,3 @@
+export interface Props {
+  keys: string[];
+}
 

diff --git a/ooknet-design/src/components/KeyValue/KeyValue.astro b/ooknet-design/
src/components/KeyValue/KeyValue.astro
new file mode 100644
index 0000000..b55951c
--- /dev/null
+++ b/ooknet-design/src/components/KeyValue/KeyValue.astro
@@ -0,0 +1,7 @@
+---
+import Frame from "../Frame/Frame.astro";
+import { buildKeyValue, type Props } from "./KeyValue";
+
+const { entries, keyW } = Astro.props as Props;
+---
+{entries.length > 0 && <Frame build={(w) => buildKeyValue(entries, w, keyW)} />}

diff --git a/ooknet-design/src/components/Ke
yValue/KeyValue.astro b/ooknet-design/src/co
mponents/KeyValue/KeyValue.astro
new file mode 100644
index 0000000..b55951c
--- /dev/null
+++ b/ooknet-design/src/components/KeyValue/
KeyValue.astro
@@ -0,0 +1,7 @@
+---
+import Frame from "../Frame/Frame.astro";
+import { buildKeyValue, type Props } from "
./KeyValue";
+
+const { entries, keyW } = Astro.props as Pr
ops;
+---
+{entries.length > 0 && <Frame build={(w) =>
 buildKeyValue(entries, w, keyW)} />}
 

diff --git a/ooknet-design/src/components/KeyValue/KeyValue.test.ts b/ooknet-desig
n/src/components/KeyValue/KeyValue.test.ts
new file mode 100644
index 0000000..b0f7214
--- /dev/null
+++ b/ooknet-design/src/components/KeyValue/KeyValue.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { buildKeyValue } from "./KeyValue";
+import { len } from "../../lib/ascii";
+
+describe("buildKeyValue", () => {
+  it("aligns values to the widest key plus gap", () => {
+    const out = buildKeyValue([["AUTHOR", "OOKNET"], ["PUBLISHED", "2026-07-16"]]
, 48);
+    const lines = out.split("\n");
+    expect(lines[0]).toBe("AUTHOR      OOKNET");
+    expect(lines[1]).toBe("PUBLISHED   2026-07-16");
+  });
+
+  it("wraps long values under their own column", () => {
+    const out = buildKeyValue([["NOTE", "a value long enough that it must wrap on
 narrow frames"]], 32);
+    const lines = out.split("\n");
+    expect(lines.length).toBeGreaterThan(1);
+    expect(lines[1].startsWith(" ".repeat(7))).toBe(true);
+    for (const ln of lines) expect(len(ln)).toBeLessThanOrEqual(32);
+  });
+
+  it("respects an explicit key width", () => {
+    const out = buildKeyValue([["A", "B"]], 48, 12);
+    expect(out).toBe("A           B");
+  });
+
+  it("handles empty entries", () => {
+    expect(buildKeyValue([], 48)).toBe("");
+  });
+});

diff --git a/ooknet-design/src/components/Ke
yValue/KeyValue.test.ts b/ooknet-design/src/
components/KeyValue/KeyValue.test.ts
new file mode 100644
index 0000000..b0f7214
--- /dev/null
+++ b/ooknet-design/src/components/KeyValue/
KeyValue.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vites
t";
+import { buildKeyValue } from "./KeyValue";
+import { len } from "../../lib/ascii";
+
+describe("buildKeyValue", () => {
+  it("aligns values to the widest key plus 
gap", () => {
+    const out = buildKeyValue([["AUTHOR", "
OOKNET"], ["PUBLISHED", "2026-07-16"]], 48);
+    const lines = out.split("\n");
+    expect(lines[0]).toBe("AUTHOR      OOKN
ET");
+    expect(lines[1]).toBe("PUBLISHED   2026
-07-16");
+  });
+
+  it("wraps long values under their own col
umn", () => {
+    const out = buildKeyValue([["NOTE", "a 
value long enough that it must wrap on narro
w frames"]], 32);
+    const lines = out.split("\n");
+    expect(lines.length).toBeGreaterThan(1)
;
+    expect(lines[1].startsWith(" ".repeat(7
))).toBe(true);
+    for (const ln of lines) expect(len(ln))
.toBeLessThanOrEqual(32);
+  });
+
+  it("respects an explicit key width", () =
> {
+    const out = buildKeyValue([["A", "B"]],
 48, 12);
+    expect(out).toBe("A           B");
+  });
+
+  it("handles empty entries", () => {
+    expect(buildKeyValue([], 48)).toBe("");
+  });
+});
 

diff --git a/ooknet-design/src/components/KeyValue/KeyValue.ts b/ooknet-design/src
/components/KeyValue/KeyValue.ts
new file mode 100644
index 0000000..0628923
--- /dev/null
+++ b/ooknet-design/src/components/KeyValue/KeyValue.ts
@@ -0,0 +1,24 @@
+// Labeled key/value rows with values word-wrapped under their column -
+// the pattern ArticleMeta hand-rolls, as a reusable block.
+import { checkGrid, len, pad, wrap } from "../../lib/ascii";
+
+export interface Props {
+  entries: [string, string][];
+  keyW?: number;
+}
+
+export function buildKeyValue(entries: [string, string][], width: number, keyW?: 
number): string {
+  if (!entries.length) return "";
+  const kw = keyW ?? Math.max(...entries.map(([k]) => len(k))) + 3;
+  const valW = Math.max(1, width - kw);
+
+  const lines: string[] = [];
+  for (const [key, value] of entries) {
+    wrap(value, valW)
+      .split("\n")
+      .forEach((ln, i) => {
+        lines.push(pad(i === 0 ? key : "", kw) + ln);
+      });
+  }
+  return checkGrid(lines.join("\n"), width, "key-value");
+}

diff --git a/ooknet-design/src/components/Ke
yValue/KeyValue.ts b/ooknet-design/src/compo
nents/KeyValue/KeyValue.ts
new file mode 100644
index 0000000..0628923
--- /dev/null
+++ b/ooknet-design/src/components/KeyValue/
KeyValue.ts
@@ -0,0 +1,24 @@
+// Labeled key/value rows with values word-
wrapped under their column -
+// the pattern ArticleMeta hand-rolls, as a
 reusable block.
+import { checkGrid, len, pad, wrap } from "
../../lib/ascii";
+
+export interface Props {
+  entries: [string, string][];
+  keyW?: number;
+}
+
+export function buildKeyValue(entries: [str
ing, string][], width: number, keyW?: number
): string {
+  if (!entries.length) return "";
+  const kw = keyW ?? Math.max(...entries.ma
p(([k]) => len(k))) + 3;
+  const valW = Math.max(1, width - kw);
+
+  const lines: string[] = [];
+  for (const [key, value] of entries) {
+    wrap(value, valW)
+      .split("\n")
+      .forEach((ln, i) => {
+        lines.push(pad(i === 0 ? key : "", 
kw) + ln);
+      });
+  }
+  return checkGrid(lines.join("\n"), width,
 "key-value");
+}
 

diff --git a/ooknet-design/src/components/NumberStepper/NumberStepper.astro b/ookn
et-design/src/components/NumberStepper/NumberStepper.astro
new file mode 100644
index 0000000..d8fd1a5
--- /dev/null
+++ b/ooknet-design/src/components/NumberStepper/NumberStepper.astro
@@ -0,0 +1,30 @@
+---
+// [ - ] 042 [ + ] - native number input between two step buttons.
+import type { Props } from "./NumberStepper";
+import "./NumberStepper.scss";
+
+const { name, value = 0, min, max, step = 1, cells = 4 } = Astro.props as Props;
+---
+<span class="num-stepper"><button type="button" class="step-btn" data-step="down"
 aria-label={`Decrease ${name}`}>[ - ]</button>{" "}<input
+  class="num-input"
+  type="number"
+  name={name}
+  value={value}
+  min={min}
+  max={max}
+  step={step}
+  aria-label={name}
+  style={`width: calc(var(--cell-w) * ${cells})`}
+/>{" "}<button type="button" class="step-btn" data-step="up" aria-label={`Increas
e ${name}`}>[ + ]</button></span>
+
+<script>
+  document.addEventListener("click", (e) => {
+    const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".step-btn")
;
+    if (!btn) return;
+    const input = btn.closest(".num-stepper")?.querySelector<HTMLInputElement>(".
num-input");
+    if (!input) return;
+    if (btn.dataset.step === "up") input.stepUp();
+    else input.stepDown();
+    input.dispatchEvent(new Event("change", { bubbles: true }));
+  });
+</script>

diff --git a/ooknet-design/src/components/Nu
mberStepper/NumberStepper.astro b/ooknet-des
ign/src/components/NumberStepper/NumberStepp
er.astro
new file mode 100644
index 0000000..d8fd1a5
--- /dev/null
+++ b/ooknet-design/src/components/NumberSte
pper/NumberStepper.astro
@@ -0,0 +1,30 @@
+---
+// [ - ] 042 [ + ] - native number input be
tween two step buttons.
+import type { Props } from "./NumberStepper
";
+import "./NumberStepper.scss";
+
+const { name, value = 0, min, max, step = 1
, cells = 4 } = Astro.props as Props;
+---
+<span class="num-stepper"><button type="but
ton" class="step-btn" data-step="down" aria-
label={`Decrease ${name}`}>[ - ]</button>{" 
"}<input
+  class="num-input"
+  type="number"
+  name={name}
+  value={value}
+  min={min}
+  max={max}
+  step={step}
+  aria-label={name}
+  style={`width: calc(var(--cell-w) * ${cel
ls})`}
+/>{" "}<button type="button" class="step-bt
n" data-step="up" aria-label={`Increase ${na
me}`}>[ + ]</button></span>
+
+<script>
+  document.addEventListener("click", (e) =>
 {
+    const btn = (e.target as HTMLElement).c
losest<HTMLButtonElement>(".step-btn");
+    if (!btn) return;
+    const input = btn.closest(".num-stepper
")?.querySelector<HTMLInputElement>(".num-in
put");
+    if (!input) return;
+    if (btn.dataset.step === "up") input.st
epUp();
+    else input.stepDown();
+    input.dispatchEvent(new Event("change",
 { bubbles: true }));
+  });
+</script>
 

diff --git a/ooknet-design/src/components/NumberStepper/NumberStepper.scss b/ookne
t-design/src/components/NumberStepper/NumberStepper.scss
new file mode 100644
index 0000000..efa2c3e
--- /dev/null
+++ b/ooknet-design/src/components/NumberStepper/NumberStepper.scss
@@ -0,0 +1,48 @@
+.num-stepper {
+  white-space: pre;
+
+  .step-btn {
+    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;
+    }
+  }
+
+  .num-input {
+    font: inherit;
+    color: inherit;
+    background: transparent;
+    border: 0;
+    outline: 0;
+    padding: 0;
+    margin: 0;
+    text-align: center;
+    -moz-appearance: textfield;
+    appearance: textfield;
+
+    &::-webkit-outer-spin-button,
+    &::-webkit-inner-spin-button {
+      -webkit-appearance: none;
+      margin: 0;
+    }
+
+    &:focus-visible {
+      outline: 1px dashed currentColor;
+      outline-offset: 2px;
+    }
+  }
+}

diff --git a/ooknet-design/src/components/Nu
mberStepper/NumberStepper.scss b/ooknet-desi
gn/src/components/NumberStepper/NumberSteppe
r.scss
new file mode 100644
index 0000000..efa2c3e
--- /dev/null
+++ b/ooknet-design/src/components/NumberSte
pper/NumberStepper.scss
@@ -0,0 +1,48 @@
+.num-stepper {
+  white-space: pre;
+
+  .step-btn {
+    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;
+    }
+  }
+
+  .num-input {
+    font: inherit;
+    color: inherit;
+    background: transparent;
+    border: 0;
+    outline: 0;
+    padding: 0;
+    margin: 0;
+    text-align: center;
+    -moz-appearance: textfield;
+    appearance: textfield;
+
+    &::-webkit-outer-spin-button,
+    &::-webkit-inner-spin-button {
+      -webkit-appearance: none;
+      margin: 0;
+    }
+
+    &:focus-visible {
+      outline: 1px dashed currentColor;
+      outline-offset: 2px;
+    }
+  }
+}
 

diff --git a/ooknet-design/src/components/NumberStepper/NumberStepper.ts b/ooknet-
design/src/components/NumberStepper/NumberStepper.ts
new file mode 100644
index 0000000..21e3950
--- /dev/null
+++ b/ooknet-design/src/components/NumberStepper/NumberStepper.ts
@@ -0,0 +1,12 @@
+export interface Props {
+  name: string;
+  value?: number;
+  min?: number;
+  max?: number;
+  step?: number;
+  /** Digit cells for the input. */
+  cells?: number;
+}
+
+// "[ - ] " + cells + " [ + ]"
+export const stepperWidth = (cells = 4) => 12 + cells;

diff --git a/ooknet-design/src/components/Nu
mberStepper/NumberStepper.ts b/ooknet-design
/src/components/NumberStepper/NumberStepper.
ts
new file mode 100644
index 0000000..21e3950
--- /dev/null
+++ b/ooknet-design/src/components/NumberSte
pper/NumberStepper.ts
@@ -0,0 +1,12 @@
+export interface Props {
+  name: string;
+  value?: number;
+  min?: number;
+  max?: number;
+  step?: number;
+  /** Digit cells for the input. */
+  cells?: number;
+}
+
+// "[ - ] " + cells + " [ + ]"
+export const stepperWidth = (cells = 4) => 
12 + cells;
 

diff --git a/ooknet-design/src/components/Pagination/Pagination.astro b/ooknet-des
ign/src/components/Pagination/Pagination.astro
new file mode 100644
index 0000000..c6843e8
--- /dev/null
+++ b/ooknet-design/src/components/Pagination/Pagination.astro
@@ -0,0 +1,27 @@
+---
+import Pre from "../Pre/Pre.astro";
+import { paginationItems, type Props } from "./Pagination";
+import "./Pagination.scss";
+
+const { current, total, href } = Astro.props as Props;
+const items = paginationItems(current, total);
+const prev = current > 1 ? href(current - 1) : null;
+const next = current < total ? href(current + 1) : null;
+---
+{total > 1 && (
+  <nav class="pager" aria-label="Pagination">
+    <Pre>{prev
+      ? <a class="pager-arrow" href={prev} aria-label="Previous page">{"[ < ]"}</
a>
+      : <span class="pager-arrow disabled">{"[ < ]"}</span>
+    }{"  "}{items.map((it, i) => (
+      <Fragment>{i > 0 ? " " : ""}{
+        it.kind === "gap" ? <span class="pager-gap">...</span>
+        : it.current ? <span class="pager-current" aria-current="page">[{it.n}]</
span>
+        : <a class="pager-page" href={href(it.n)}>{it.n}</a>
+      }</Fragment>
+    ))}{"  "}{next
+      ? <a class="pager-arrow" href={next} aria-label="Next page">{"[ > ]"}</a>
+      : <span class="pager-arrow disabled">{"[ > ]"}</span>
+    }</Pre>
+  </nav>
+)}

diff --git a/ooknet-design/src/components/Pa
gination/Pagination.astro b/ooknet-design/sr
c/components/Pagination/Pagination.astro
new file mode 100644
index 0000000..c6843e8
--- /dev/null
+++ b/ooknet-design/src/components/Paginatio
n/Pagination.astro
@@ -0,0 +1,27 @@
+---
+import Pre from "../Pre/Pre.astro";
+import { paginationItems, type Props } from
 "./Pagination";
+import "./Pagination.scss";
+
+const { current, total, href } = Astro.prop
s as Props;
+const items = paginationItems(current, tota
l);
+const prev = current > 1 ? href(current - 1
) : null;
+const next = current < total ? href(current
 + 1) : null;
+---
+{total > 1 && (
+  <nav class="pager" aria-label="Pagination
">
+    <Pre>{prev
+      ? <a class="pager-arrow" href={prev} 
aria-label="Previous page">{"[ < ]"}</a>
+      : <span class="pager-arrow disabled">
{"[ < ]"}</span>
+    }{"  "}{items.map((it, i) => (
+      <Fragment>{i > 0 ? " " : ""}{
+        it.kind === "gap" ? <span class="pa
ger-gap">...</span>
+        : it.current ? <span class="pager-c
urrent" aria-current="page">[{it.n}]</span>
+        : <a class="pager-page" href={href(
it.n)}>{it.n}</a>
+      }</Fragment>
+    ))}{"  "}{next
+      ? <a class="pager-arrow" href={next} 
aria-label="Next page">{"[ > ]"}</a>
+      : <span class="pager-arrow disabled">
{"[ > ]"}</span>
+    }</Pre>
+  </nav>
+)}
 

diff --git a/ooknet-design/src/components/Pagination/Pagination.scss b/ooknet-desi
gn/src/components/Pagination/Pagination.scss
new file mode 100644
index 0000000..b0d09e9
--- /dev/null
+++ b/ooknet-design/src/components/Pagination/Pagination.scss
@@ -0,0 +1,27 @@
+.pager {
+  a {
+    color: var(--ink);
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+      text-underline-offset: 3px;
+    }
+
+    &:focus-visible {
+      outline: 1px dashed var(--ink);
+      outline-offset: 1px;
+    }
+  }
+
+  .pager-current {
+    font-weight: 700;
+    background: var(--ink);
+    color: var(--paper);
+  }
+
+  .pager-gap,
+  .pager-arrow.disabled {
+    color: var(--ink-soft);
+  }
+}

diff --git a/ooknet-design/src/components/Pa
gination/Pagination.scss b/ooknet-design/src
/components/Pagination/Pagination.scss
new file mode 100644
index 0000000..b0d09e9
--- /dev/null
+++ b/ooknet-design/src/components/Paginatio
n/Pagination.scss
@@ -0,0 +1,27 @@
+.pager {
+  a {
+    color: var(--ink);
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+      text-underline-offset: 3px;
+    }
+
+    &:focus-visible {
+      outline: 1px dashed var(--ink);
+      outline-offset: 1px;
+    }
+  }
+
+  .pager-current {
+    font-weight: 700;
+    background: var(--ink);
+    color: var(--paper);
+  }
+
+  .pager-gap,
+  .pager-arrow.disabled {
+    color: var(--ink-soft);
+  }
+}
 

diff --git a/ooknet-design/src/components/Pagination/Pagination.test.ts b/ooknet-d
esign/src/components/Pagination/Pagination.test.ts
new file mode 100644
index 0000000..bafc20c
--- /dev/null
+++ b/ooknet-design/src/components/Pagination/Pagination.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+import { paginationItems, type PageItem } from "./Pagination";
+
+const render = (items: PageItem[]) =>
+  items.map((it) => (it.kind === "gap" ? "..." : it.current ? `[${it.n}]` : Strin
g(it.n))).join(" ");
+
+describe("paginationItems", () => {
+  it("windows around the current page with edge pages pinned", () => {
+    expect(render(paginationItems(6, 20))).toBe("1 ... 4 5 [6] 7 8 ... 20");
+  });
+
+  it("collapses gaps that would hide a single page", () => {
+    expect(render(paginationItems(4, 20))).toBe("1 2 3 [4] 5 6 ... 20");
+  });
+
+  it("handles the first and last pages", () => {
+    expect(render(paginationItems(1, 20))).toBe("[1] 2 3 4 5 ... 20");
+    expect(render(paginationItems(20, 20))).toBe("1 ... 16 17 18 19 [20]");
+  });
+
+  it("shows every page when total fits the window", () => {
+    expect(render(paginationItems(2, 4))).toBe("1 [2] 3 4");
+  });
+
+  it("clamps out-of-range current pages", () => {
+    expect(render(paginationItems(99, 3))).toBe("1 2 [3]");
+  });
+});

diff --git a/ooknet-design/src/components/Pa
gination/Pagination.test.ts b/ooknet-design/
src/components/Pagination/Pagination.test.ts
new file mode 100644
index 0000000..bafc20c
--- /dev/null
+++ b/ooknet-design/src/components/Paginatio
n/Pagination.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vites
t";
+import { paginationItems, type PageItem } f
rom "./Pagination";
+
+const render = (items: PageItem[]) =>
+  items.map((it) => (it.kind === "gap" ? ".
.." : it.current ? `[${it.n}]` : String(it.n
))).join(" ");
+
+describe("paginationItems", () => {
+  it("windows around the current page with 
edge pages pinned", () => {
+    expect(render(paginationItems(6, 20))).
toBe("1 ... 4 5 [6] 7 8 ... 20");
+  });
+
+  it("collapses gaps that would hide a sing
le page", () => {
+    expect(render(paginationItems(4, 20))).
toBe("1 2 3 [4] 5 6 ... 20");
+  });
+
+  it("handles the first and last pages", ()
 => {
+    expect(render(paginationItems(1, 20))).
toBe("[1] 2 3 4 5 ... 20");
+    expect(render(paginationItems(20, 20)))
.toBe("1 ... 16 17 18 19 [20]");
+  });
+
+  it("shows every page when total fits the 
window", () => {
+    expect(render(paginationItems(2, 4))).t
oBe("1 [2] 3 4");
+  });
+
+  it("clamps out-of-range current pages", (
) => {
+    expect(render(paginationItems(99, 3))).
toBe("1 2 [3]");
+  });
+});
 

diff --git a/ooknet-design/src/components/Pagination/Pagination.ts b/ooknet-design
/src/components/Pagination/Pagination.ts
new file mode 100644
index 0000000..c0cbbaf
--- /dev/null
+++ b/ooknet-design/src/components/Pagination/Pagination.ts
@@ -0,0 +1,33 @@
+// Numbered pager: 1 ... 4 5 [6] 7 8 ... 20. First and last pages always
+// show; a sliding window surrounds the current page.
+export interface Props {
+  current: number;
+  total: number;
+  href: (n: number) => string;
+}
+
+export type PageItem =
+  | { kind: "page"; n: number; current: boolean }
+  | { kind: "gap" };
+
+export function paginationItems(current: number, total: number, windowSize = 5): 
PageItem[] {
+  const cur = Math.max(1, Math.min(total, current));
+  if (total < 1) return [];
+
+  const half = Math.floor(windowSize / 2);
+  let start = Math.max(1, cur - half);
+  const end = Math.min(total, start + windowSize - 1);
+  start = Math.max(1, end - windowSize + 1);
+
+  const items: PageItem[] = [];
+  if (start > 1) {
+    items.push({ kind: "page", n: 1, current: cur === 1 });
+    if (start > 2) items.push({ kind: "gap" });
+  }
+  for (let n = start; n <= end; n++) items.push({ kind: "page", n, current: n ===
 cur });
+  if (end < total) {
+    if (end < total - 1) items.push({ kind: "gap" });
+    items.push({ kind: "page", n: total, current: cur === total });
+  }
+  return items;
+}

diff --git a/ooknet-design/src/components/Pa
gination/Pagination.ts b/ooknet-design/src/c
omponents/Pagination/Pagination.ts
new file mode 100644
index 0000000..c0cbbaf
--- /dev/null
+++ b/ooknet-design/src/components/Paginatio
n/Pagination.ts
@@ -0,0 +1,33 @@
+// Numbered pager: 1 ... 4 5 [6] 7 8 ... 20
. First and last pages always
+// show; a sliding window surrounds the cur
rent page.
+export interface Props {
+  current: number;
+  total: number;
+  href: (n: number) => string;
+}
+
+export type PageItem =
+  | { kind: "page"; n: number; current: boo
lean }
+  | { kind: "gap" };
+
+export function paginationItems(current: nu
mber, total: number, windowSize = 5): PageIt
em[] {
+  const cur = Math.max(1, Math.min(total, c
urrent));
+  if (total < 1) return [];
+
+  const half = Math.floor(windowSize / 2);
+  let start = Math.max(1, cur - half);
+  const end = Math.min(total, start + windo
wSize - 1);
+  start = Math.max(1, end - windowSize + 1)
;
+
+  const items: PageItem[] = [];
+  if (start > 1) {
+    items.push({ kind: "page", n: 1, curren
t: cur === 1 });
+    if (start > 2) items.push({ kind: "gap"
 });
+  }
+  for (let n = start; n <= end; n++) items.
push({ kind: "page", n, current: n === cur }
);
+  if (end < total) {
+    if (end < total - 1) items.push({ kind:
 "gap" });
+    items.push({ kind: "page", n: total, cu
rrent: cur === total });
+  }
+  return items;
+}
 

diff --git a/ooknet-design/src/components/Sparkline/Sparkline.astro b/ooknet-desig
n/src/components/Sparkline/Sparkline.astro
new file mode 100644
index 0000000..829c22f
--- /dev/null
+++ b/ooknet-design/src/components/Sparkline/Sparkline.astro
@@ -0,0 +1,9 @@
+---
+import { buildSparkline, type Props } from "./Sparkline";
+
+const { values, width } = Astro.props as Props;
+const label = values.length
+  ? `series of ${values.length} values, latest ${values[values.length - 1]}`
+  : "empty series";
+---
+<span class="sparkline" role="img" aria-label={label}>{buildSparkline(values, wid
th)}</span>

diff --git a/ooknet-design/src/components/Sp
arkline/Sparkline.astro b/ooknet-design/src/
components/Sparkline/Sparkline.astro
new file mode 100644
index 0000000..829c22f
--- /dev/null
+++ b/ooknet-design/src/components/Sparkline
/Sparkline.astro
@@ -0,0 +1,9 @@
+---
+import { buildSparkline, type Props } from 
"./Sparkline";
+
+const { values, width } = Astro.props as Pr
ops;
+const label = values.length
+  ? `series of ${values.length} values, lat
est ${values[values.length - 1]}`
+  : "empty series";
+---
+<span class="sparkline" role="img" aria-lab
el={label}>{buildSparkline(values, width)}</
span>
 

diff --git a/ooknet-design/src/components/Sparkline/Sparkline.test.ts b/ooknet-des
ign/src/components/Sparkline/Sparkline.test.ts
new file mode 100644
index 0000000..7e58dff
--- /dev/null
+++ b/ooknet-design/src/components/Sparkline/Sparkline.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { buildSparkline } from "./Sparkline";
+
+describe("buildSparkline", () => {
+  it("emits one glyph per value", () => {
+    expect([...buildSparkline([1, 2, 3])]).toHaveLength(3);
+  });
+
+  it("maps min to the lowest block and max to the highest", () => {
+    const s = [...buildSparkline([0, 100])];
+    expect(s[0]).toBe("▁");
+    expect(s[1]).toBe("█");
+  });
+
+  it("renders a flat series at mid height", () => {
+    expect(buildSparkline([5, 5, 5])).toBe("▄▄▄");
+  });
+
+  it("bucket-averages long series down to width", () => {
+    const s = buildSparkline(Array.from({ length: 100 }, (_, i) => i), 10);
+    expect([...s]).toHaveLength(10);
+  });
+
+  it("handles empty input", () => {
+    expect(buildSparkline([])).toBe("");
+  });
+});

diff --git a/ooknet-design/src/components/Sp
arkline/Sparkline.test.ts b/ooknet-design/sr
c/components/Sparkline/Sparkline.test.ts
new file mode 100644
index 0000000..7e58dff
--- /dev/null
+++ b/ooknet-design/src/components/Sparkline
/Sparkline.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vites
t";
+import { buildSparkline } from "./Sparkline
";
+
+describe("buildSparkline", () => {
+  it("emits one glyph per value", () => {
+    expect([...buildSparkline([1, 2, 3])]).
toHaveLength(3);
+  });
+
+  it("maps min to the lowest block and max 
to the highest", () => {
+    const s = [...buildSparkline([0, 100])]
;
+    expect(s[0]).toBe("▁");
+    expect(s[1]).toBe("█");
+  });
+
+  it("renders a flat series at mid height",
 () => {
+    expect(buildSparkline([5, 5, 5])).toBe(
"▄▄▄");
+  });
+
+  it("bucket-averages long series down to w
idth", () => {
+    const s = buildSparkline(Array.from({ l
ength: 100 }, (_, i) => i), 10);
+    expect([...s]).toHaveLength(10);
+  });
+
+  it("handles empty input", () => {
+    expect(buildSparkline([])).toBe("");
+  });
+});
 

diff --git a/ooknet-design/src/components/Sparkline/Sparkline.ts b/ooknet-design/s
rc/components/Sparkline/Sparkline.ts
new file mode 100644
index 0000000..d01b807
--- /dev/null
+++ b/ooknet-design/src/components/Sparkline/Sparkline.ts
@@ -0,0 +1,26 @@
+// Inline series as block glyphs: ▁▂▄▇▅▂ - all within ON_GRID.
+export interface Props {
+  values: number[];
+  /** Max glyphs; longer series bucket-average down. */
+  width?: number;
+}
+
+const BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
+
+export function buildSparkline(values: number[], width = values.length): string {
+  if (!values.length || width < 1) return "";
+
+  const n = Math.min(width, values.length);
+  const buckets = Array.from({ length: n }, (_, i) => {
+    const start = Math.floor((i * values.length) / n);
+    const end = Math.max(start + 1, Math.floor(((i + 1) * values.length) / n));
+    const slice = values.slice(start, end);
+    return slice.reduce((a, b) => a + b, 0) / slice.length;
+  });
+
+  const min = Math.min(...buckets);
+  const span = Math.max(...buckets) - min;
+  return buckets
+    .map((v) => BLOCKS[span === 0 ? 3 : Math.min(7, Math.floor(((v - min) / span)
 * 8))])
+    .join("");
+}

diff --git a/ooknet-design/src/components/Sp
arkline/Sparkline.ts b/ooknet-design/src/com
ponents/Sparkline/Sparkline.ts
new file mode 100644
index 0000000..d01b807
--- /dev/null
+++ b/ooknet-design/src/components/Sparkline
/Sparkline.ts
@@ -0,0 +1,26 @@
+// Inline series as block glyphs: ▁▂▄▇▅▂ - 
all within ON_GRID.
+export interface Props {
+  values: number[];
+  /** Max glyphs; longer series bucket-aver
age down. */
+  width?: number;
+}
+
+const BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆
", "▇", "█"];
+
+export function buildSparkline(values: numb
er[], width = values.length): string {
+  if (!values.length || width < 1) return "
";
+
+  const n = Math.min(width, values.length);
+  const buckets = Array.from({ length: n },
 (_, i) => {
+    const start = Math.floor((i * values.le
ngth) / n);
+    const end = Math.max(start + 1, Math.fl
oor(((i + 1) * values.length) / n));
+    const slice = values.slice(start, end);
+    return slice.reduce((a, b) => a + b, 0)
 / slice.length;
+  });
+
+  const min = Math.min(...buckets);
+  const span = Math.max(...buckets) - min;
+  return buckets
+    .map((v) => BLOCKS[span === 0 ? 3 : Mat
h.min(7, Math.floor(((v - min) / span) * 8))
])
+    .join("");
+}
 

diff --git a/ooknet-design/src/components/Steps/Steps.astro b/ooknet-design/src/co
mponents/Steps/Steps.astro
new file mode 100644
index 0000000..ec68def
--- /dev/null
+++ b/ooknet-design/src/components/Steps/Steps.astro
@@ -0,0 +1,7 @@
+---
+import Frame from "../Frame/Frame.astro";
+import { buildSteps, type Props } from "./Steps";
+
+const { steps, current } = Astro.props as Props;
+---
+<Frame build={(w) => buildSteps(steps, current, w)} />

diff --git a/ooknet-design/src/components/St
eps/Steps.astro b/ooknet-design/src/componen
ts/Steps/Steps.astro
new file mode 100644
index 0000000..ec68def
--- /dev/null
+++ b/ooknet-design/src/components/Steps/Ste
ps.astro
@@ -0,0 +1,7 @@
+---
+import Frame from "../Frame/Frame.astro";
+import { buildSteps, type Props } from "./S
teps";
+
+const { steps, current } = Astro.props as P
rops;
+---
+<Frame build={(w) => buildSteps(steps, curr
ent, w)} />
 

diff --git a/ooknet-design/src/components/Steps/Steps.test.ts b/ooknet-design/src/
components/Steps/Steps.test.ts
new file mode 100644
index 0000000..e57b1e0
--- /dev/null
+++ b/ooknet-design/src/components/Steps/Steps.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { buildSteps } from "./Steps";
+import { len } from "../../lib/ascii";
+
+const STEPS = ["FETCH", "PARSE", "RENDER"];
+
+describe("buildSteps", () => {
+  it("marks done, current, and pending steps", () => {
+    const out = buildSteps(STEPS, 1, 86);
+    expect(out).toBe("[x] FETCH ── [?] PARSE ── [ ] RENDER");
+  });
+
+  it("stays horizontal when it fits", () => {
+    expect(buildSteps(STEPS, 0, 86)).not.toContain("\n");
+  });
+
+  it("stacks on a vertical spine when too wide", () => {
+    const out = buildSteps(["A LONG STEP NAME", "ANOTHER LONG STEP", "A THIRD STE
P"], 1, 30);
+    const lines = out.split("\n");
+    expect(lines.length).toBe(5);
+    expect(lines[1]).toBe(" │");
+    for (const ln of lines) expect(len(ln)).toBeLessThanOrEqual(30);
+  });
+});

diff --git a/ooknet-design/src/components/St
eps/Steps.test.ts b/ooknet-design/src/compon
ents/Steps/Steps.test.ts
new file mode 100644
index 0000000..e57b1e0
--- /dev/null
+++ b/ooknet-design/src/components/Steps/Ste
ps.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vites
t";
+import { buildSteps } from "./Steps";
+import { len } from "../../lib/ascii";
+
+const STEPS = ["FETCH", "PARSE", "RENDER"];
+
+describe("buildSteps", () => {
+  it("marks done, current, and pending step
s", () => {
+    const out = buildSteps(STEPS, 1, 86);
+    expect(out).toBe("[x] FETCH ── [?] PARS
E ── [ ] RENDER");
+  });
+
+  it("stays horizontal when it fits", () =>
 {
+    expect(buildSteps(STEPS, 0, 86)).not.to
Contain("\n");
+  });
+
+  it("stacks on a vertical spine when too w
ide", () => {
+    const out = buildSteps(["A LONG STEP NA
ME", "ANOTHER LONG STEP", "A THIRD STEP"], 1
, 30);
+    const lines = out.split("\n");
+    expect(lines.length).toBe(5);
+    expect(lines[1]).toBe(" │");
+    for (const ln of lines) expect(len(ln))
.toBeLessThanOrEqual(30);
+  });
+});
 

diff --git a/ooknet-design/src/components/Steps/Steps.ts b/ooknet-design/src/compo
nents/Steps/Steps.ts
new file mode 100644
index 0000000..ddde1e8
--- /dev/null
+++ b/ooknet-design/src/components/Steps/Steps.ts
@@ -0,0 +1,26 @@
+// Process steps: horizontal chain when it fits the frame, vertical
+// spine otherwise. [x] done, [?] current, [ ] pending.
+import { G, checkGrid, len } from "../../lib/ascii";
+
+export interface Props {
+  steps: string[];
+  /** 0-based index of the current step. */
+  current: number;
+}
+
+const mark = (i: number, current: number) =>
+  i < current ? "[x]" : i === current ? "[?]" : "[ ]";
+
+export function buildSteps(steps: string[], current: number, width: number): stri
ng {
+  const parts = steps.map((s, i) => `${mark(i, current)} ${s}`);
+
+  const horizontal = parts.join(` ${G.H}${G.H} `);
+  if (len(horizontal) <= width) return checkGrid(horizontal, width, "steps");
+
+  const lines: string[] = [];
+  parts.forEach((p, i) => {
+    lines.push(p);
+    if (i < parts.length - 1) lines.push(` ${G.V}`);
+  });
+  return checkGrid(lines.join("\n"), width, "steps");
+}

diff --git a/ooknet-design/src/components/St
eps/Steps.ts b/ooknet-design/src/components/
Steps/Steps.ts
new file mode 100644
index 0000000..ddde1e8
--- /dev/null
+++ b/ooknet-design/src/components/Steps/Ste
ps.ts
@@ -0,0 +1,26 @@
+// Process steps: horizontal chain when it 
fits the frame, vertical
+// spine otherwise. [x] done, [?] current, 
[ ] pending.
+import { G, checkGrid, len } from "../../li
b/ascii";
+
+export interface Props {
+  steps: string[];
+  /** 0-based index of the current step. */
+  current: number;
+}
+
+const mark = (i: number, current: number) =
>
+  i < current ? "[x]" : i === current ? "[?
]" : "[ ]";
+
+export function buildSteps(steps: string[],
 current: number, width: number): string {
+  const parts = steps.map((s, i) => `${mark
(i, current)} ${s}`);
+
+  const horizontal = parts.join(` ${G.H}${G
.H} `);
+  if (len(horizontal) <= width) return chec
kGrid(horizontal, width, "steps");
+
+  const lines: string[] = [];
+  parts.forEach((p, i) => {
+    lines.push(p);
+    if (i < parts.length - 1) lines.push(` 
${G.V}`);
+  });
+  return checkGrid(lines.join("\n"), width,
 "steps");
+}
 

diff --git a/ooknet-design/src/components/Switch/Switch.astro b/ooknet-design/src/
components/Switch/Switch.astro
new file mode 100644
index 0000000..8e1e482
--- /dev/null
+++ b/ooknet-design/src/components/Switch/Switch.astro
@@ -0,0 +1,9 @@
+---
+// Two-state toggle on a checkbox, following the Checkbox pattern: the
+// native input is visually removed, the mark renders 7 stable cells.
+import type { Props } from "./Switch";
+import "./Switch.scss";
+
+const { label, name, checked = false, disabled = false } = Astro.props as Props;
+---
+<label class="switch"><input type="checkbox" role="switch" name={name} checked={c
hecked} disabled={disabled} /><span class="switch-mark" aria-hidden="true"></span>
{" " + label}</label>

diff --git a/ooknet-design/src/components/Sw
itch/Switch.astro b/ooknet-design/src/compon
ents/Switch/Switch.astro
new file mode 100644
index 0000000..8e1e482
--- /dev/null
+++ b/ooknet-design/src/components/Switch/Sw
itch.astro
@@ -0,0 +1,9 @@
+---
+// Two-state toggle on a checkbox, followin
g the Checkbox pattern: the
+// native input is visually removed, the ma
rk renders 7 stable cells.
+import type { Props } from "./Switch";
+import "./Switch.scss";
+
+const { label, name, checked = false, disab
led = false } = Astro.props as Props;
+---
+<label class="switch"><input type="checkbox
" role="switch" name={name} checked={checked
} disabled={disabled} /><span class="switch-
mark" aria-hidden="true"></span>{" " + label
}</label>
 

diff --git a/ooknet-design/src/components/Switch/Switch.scss b/ooknet-design/src/c
omponents/Switch/Switch.scss
new file mode 100644
index 0000000..8a14b8e
--- /dev/null
+++ b/ooknet-design/src/components/Switch/Switch.scss
@@ -0,0 +1,40 @@
+.switch {
+  cursor: pointer;
+  white-space: pre;
+
+  input {
+    position: absolute;
+    opacity: 0;
+    width: 1px;
+    height: 1px;
+    margin: 0;
+    pointer-events: none;
+  }
+
+  .switch-mark::before {
+    content: "[ off ]";
+    color: var(--ink-soft);
+  }
+  input:checked + .switch-mark::before {
+    content: "[ ON  ]";
+    color: var(--paper);
+    background: var(--ink);
+    font-weight: 700;
+  }
+
+  input:focus-visible + .switch-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/Sw
itch/Switch.scss b/ooknet-design/src/compone
nts/Switch/Switch.scss
new file mode 100644
index 0000000..8a14b8e
--- /dev/null
+++ b/ooknet-design/src/components/Switch/Sw
itch.scss
@@ -0,0 +1,40 @@
+.switch {
+  cursor: pointer;
+  white-space: pre;
+
+  input {
+    position: absolute;
+    opacity: 0;
+    width: 1px;
+    height: 1px;
+    margin: 0;
+    pointer-events: none;
+  }
+
+  .switch-mark::before {
+    content: "[ off ]";
+    color: var(--ink-soft);
+  }
+  input:checked + .switch-mark::before {
+    content: "[ ON  ]";
+    color: var(--paper);
+    background: var(--ink);
+    font-weight: 700;
+  }
+
+  input:focus-visible + .switch-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/Switch/Switch.ts b/ooknet-design/src/com
ponents/Switch/Switch.ts
new file mode 100644
index 0000000..0917a6a
--- /dev/null
+++ b/ooknet-design/src/components/Switch/Switch.ts
@@ -0,0 +1,11 @@
+export interface Props {
+  label: string;
+  name: string;
+  checked?: boolean;
+  disabled?: boolean;
+}
+
+// "[ off ]" / "[ ON  ]" - both 7 cells.
+export const SWITCH_W = 7;
+
+export const switchWidth = (label: string) => SWITCH_W + 1 + [...label].length;

diff --git a/ooknet-design/src/components/Sw
itch/Switch.ts b/ooknet-design/src/component
s/Switch/Switch.ts
new file mode 100644
index 0000000..0917a6a
--- /dev/null
+++ b/ooknet-design/src/components/Switch/Sw
itch.ts
@@ -0,0 +1,11 @@
+export interface Props {
+  label: string;
+  name: string;
+  checked?: boolean;
+  disabled?: boolean;
+}
+
+// "[ off ]" / "[ ON  ]" - both 7 cells.
+export const SWITCH_W = 7;
+
+export const switchWidth = (label: string) 
=> SWITCH_W + 1 + [...label].length;
 

diff --git a/ooknet-design/src/pages/components.astro b/ooknet-design/src/pages/co
mponents.astro
index f2d83e2..48cc70d 100644
--- a/ooknet-design/src/pages/components.astro
+++ b/ooknet-design/src/pages/components.astro
@@ -21,6 +21,17 @@ import Tabs from "../components/Tabs/Tabs.astro";
 import Breadcrumbs from "../components/Breadcrumbs/Breadcrumbs.astro";
 import Timeline from "../components/Timeline/Timeline.astro";
 import type { TimelineEntry } from "../components/Timeline/Timeline";
+import Kbd from "../components/Kbd/Kbd.astro";
+import Gauge from "../components/Gauge/Gauge.astro";
+import Sparkline from "../components/Sparkline/Sparkline.astro";
+import KeyValue from "../components/KeyValue/KeyValue.astro";
+import Banner from "../components/Banner/Banner.astro";
+import Steps from "../components/Steps/Steps.astro";
+import Calendar from "../components/Calendar/Calendar.astro";
+import Pagination from "../components/Pagination/Pagination.astro";
+import Switch from "../components/Switch/Switch.astro";
+import NumberStepper from "../components/NumberStepper/NumberStepper.astro";
+import Dialog from "../components/Dialog/Dialog.astro";
 import { rule, ruleD, row2, pad } from "../lib/ascii";
 
 const section = (title: string, right: string) => (w: number) =>
@@ -158,6 +169,55 @@ const FILED_TREE: TreeNode[] = [
   <Timeline entries={HISTORY} />
   <Pre>{" "}</Pre>
 
+  <Frame build={section("GAUGE", "Gauge.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("SIGNAL", LABEL_W)}<Gauge value={47} threshold={70} /></Pre>
+  <Pre>{pad("BUFFER", LABEL_W)}<Gauge value={85} threshold={70} /></Pre>
+  <Pre>{pad("DECAY", LABEL_W)}<Gauge value={8} /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("SPARKLINE", "Sparkline.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("UPLINK", LABEL_W)}<Sparkline values={[3, 5, 9, 4, 6, 12, 8, 7, 11, 1
4, 9, 6]} />{"  14 PEAK"}</Pre>
+  <Pre>{pad("NOISE", LABEL_W)}<Sparkline values={[2, 2, 3, 2, 2, 2, 4, 3, 2, 2, 3
, 2]} />{"   4 PEAK"}</Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("KEY VALUE", "KeyValue.astro")} />
+  <Pre>{" "}</Pre>
+  <KeyValue entries={[
+    ["STATION", "OOKNET-9"],
+    ["FREQUENCY", "14.230 MHZ"],
+    ["REMARKS", "relay via the northern repeater when the direct path fades below
 threshold"],
+  ]} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("BANNER", "Banner.astro")} />
+  <Pre>{" "}</Pre>
+  <Banner variant="notice" message="scheduled maintenance window sunday 02:00-04:
00 UTC" />
+  <Banner variant="warning" message="frequency drift detected on channel bravo - 
recalibrate before transmitting" />
+  <Banner variant="error" message="uplink lost" />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("STEPS", "Steps.astro")} />
+  <Pre>{" "}</Pre>
+  <Steps steps={["DRAFT", "PEER CHECK", "PUBLISH"]} current={1} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("CALENDAR", "Calendar.astro")} />
+  <Pre>{" "}</Pre>
+  <Calendar year={2026} month={7} marks={[16, 17]} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("PAGINATION", "Pagination.astro")} />
+  <Pre>{" "}</Pre>
+  <Pagination current={6} total={20} href={(n) => `/?page=${n}`} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("KBD", "Kbd.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre>{"press "}<Kbd keys={["/"]} />{" to search, "}<Kbd keys={["CTRL", "K"]} />
{" for the palette"}</Pre>
+  <Pre>{" "}</Pre>
+
   <Frame build={section("TABS", "Tabs.astro")} />
   <Pre>{" "}</Pre>
   <Tabs labels={["NIX", "NPM", "MANUAL"]}>
@@ -180,6 +240,28 @@ const FILED_TREE: TreeNode[] = [
   <Breadcrumbs filed="/INFO/GRAPH/ENTROPY/BOUND" />
   <Pre>{" "}</Pre>
 
+  <Frame build={section("SWITCH", "Switch.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre><Switch label="AUTO-ACK INCOMING" name="demo-autoack" checked /></Pre>
+  <Pre><Switch label="BEACON MODE" name="demo-beacon" /></Pre>
+  <Pre><Switch label="DISABLED RELAY" name="demo-relay" disabled /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("NUMBER STEPPER", "NumberStepper.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("POWER (W)", LABEL_W)}<NumberStepper name="demo-power" value={25} min
={0} max={100} step={5} /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("DIALOG", "Dialog.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre><span data-dialog-open="demo-dialog"><Button label="OPEN TRANSMISSION LOG"
 variant="primary" /></span></Pre>
+  <Dialog id="demo-dialog" title="TRANSMISSION LOG">
+    <Pre>{"2026-07-17 09:41  N0 ACK RECEIVED"}</Pre>
+    <Pre>{"2026-07-17 09:38  BEACON SENT ON BRAVO"}</Pre>
+    <Pre>{"2026-07-17 09:12  CHANNEL SCAN COMPLETE"}</Pre>
+  </Dialog>
+  <Pre>{" "}</Pre>
+
   <Frame build={section("TOAST", "Toast.astro")} />
   <Pre>{" "}</Pre>
   <Pre><span id="toast-ok"><Button label="QUEUE" variant="primary" /></span>{"  "
}<span id="toast-error"><Button label="JAM" /></span>{"  "}<span id="toast-pending
"><Button label="HOLD" /></span></Pre>

diff --git a/ooknet-design/src/pages/compone
nts.astro b/ooknet-design/src/pages/componen
ts.astro
index f2d83e2..48cc70d 100644
--- a/ooknet-design/src/pages/components.ast
ro
+++ b/ooknet-design/src/pages/components.ast
ro
@@ -21,6 +21,17 @@ import Tabs from "../comp
onents/Tabs/Tabs.astro";
 import Breadcrumbs from "../components/Brea
dcrumbs/Breadcrumbs.astro";
 import Timeline from "../components/Timelin
e/Timeline.astro";
 import type { TimelineEntry } from "../comp
onents/Timeline/Timeline";
+import Kbd from "../components/Kbd/Kbd.astr
o";
+import Gauge from "../components/Gauge/Gaug
e.astro";
+import Sparkline from "../components/Sparkl
ine/Sparkline.astro";
+import KeyValue from "../components/KeyValu
e/KeyValue.astro";
+import Banner from "../components/Banner/Ba
nner.astro";
+import Steps from "../components/Steps/Step
s.astro";
+import Calendar from "../components/Calenda
r/Calendar.astro";
+import Pagination from "../components/Pagin
ation/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 { rule, ruleD, row2, pad } from "../
lib/ascii";
 
 const section = (title: string, right: stri
ng) => (w: number) =>
@@ -158,6 +169,55 @@ const FILED_TREE: TreeN
ode[] = [
   <Timeline entries={HISTORY} />
   <Pre>{" "}</Pre>
 
+  <Frame build={section("GAUGE", "Gauge.ast
ro")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("SIGNAL", LABEL_W)}<Gauge value
={47} threshold={70} /></Pre>
+  <Pre>{pad("BUFFER", LABEL_W)}<Gauge value
={85} threshold={70} /></Pre>
+  <Pre>{pad("DECAY", LABEL_W)}<Gauge value=
{8} /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("SPARKLINE", "Spark
line.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("UPLINK", LABEL_W)}<Sparkline v
alues={[3, 5, 9, 4, 6, 12, 8, 7, 11, 14, 9, 
6]} />{"  14 PEAK"}</Pre>
+  <Pre>{pad("NOISE", LABEL_W)}<Sparkline va
lues={[2, 2, 3, 2, 2, 2, 4, 3, 2, 2, 3, 2]} 
/>{"   4 PEAK"}</Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("KEY VALUE", "KeyVa
lue.astro")} />
+  <Pre>{" "}</Pre>
+  <KeyValue entries={[
+    ["STATION", "OOKNET-9"],
+    ["FREQUENCY", "14.230 MHZ"],
+    ["REMARKS", "relay via the northern rep
eater when the direct path fades below thres
hold"],
+  ]} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("BANNER", "Banner.a
stro")} />
+  <Pre>{" "}</Pre>
+  <Banner variant="notice" message="schedul
ed maintenance window sunday 02:00-04:00 UTC
" />
+  <Banner variant="warning" message="freque
ncy drift detected on channel bravo - recali
brate before transmitting" />
+  <Banner variant="error" message="uplink l
ost" />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("STEPS", "Steps.ast
ro")} />
+  <Pre>{" "}</Pre>
+  <Steps steps={["DRAFT", "PEER CHECK", "PU
BLISH"]} current={1} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("CALENDAR", "Calend
ar.astro")} />
+  <Pre>{" "}</Pre>
+  <Calendar year={2026} month={7} marks={[1
6, 17]} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("PAGINATION", "Pagi
nation.astro")} />
+  <Pre>{" "}</Pre>
+  <Pagination current={6} total={20} href={
(n) => `/?page=${n}`} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("KBD", "Kbd.astro")
} />
+  <Pre>{" "}</Pre>
+  <Pre>{"press "}<Kbd keys={["/"]} />{" to 
search, "}<Kbd keys={["CTRL", "K"]} />{" for
 the palette"}</Pre>
+  <Pre>{" "}</Pre>
+
   <Frame build={section("TABS", "Tabs.astro
")} />
   <Pre>{" "}</Pre>
   <Tabs labels={["NIX", "NPM", "MANUAL"]}>
@@ -180,6 +240,28 @@ const FILED_TREE: TreeN
ode[] = [
   <Breadcrumbs filed="/INFO/GRAPH/ENTROPY/B
OUND" />
   <Pre>{" "}</Pre>
 
+  <Frame build={section("SWITCH", "Switch.a
stro")} />
+  <Pre>{" "}</Pre>
+  <Pre><Switch label="AUTO-ACK INCOMING" na
me="demo-autoack" checked /></Pre>
+  <Pre><Switch label="BEACON MODE" name="de
mo-beacon" /></Pre>
+  <Pre><Switch label="DISABLED RELAY" name=
"demo-relay" disabled /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("NUMBER STEPPER", "
NumberStepper.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("POWER (W)", LABEL_W)}<NumberSt
epper name="demo-power" value={25} min={0} m
ax={100} step={5} /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("DIALOG", "Dialog.a
stro")} />
+  <Pre>{" "}</Pre>
+  <Pre><span data-dialog-open="demo-dialog"
><Button label="OPEN TRANSMISSION LOG" varia
nt="primary" /></span></Pre>
+  <Dialog id="demo-dialog" title="TRANSMISS
ION LOG">
+    <Pre>{"2026-07-17 09:41  N0 ACK RECEIVE
D"}</Pre>
+    <Pre>{"2026-07-17 09:38  BEACON SENT ON
 BRAVO"}</Pre>
+    <Pre>{"2026-07-17 09:12  CHANNEL SCAN C
OMPLETE"}</Pre>
+  </Dialog>
+  <Pre>{" "}</Pre>
+
   <Frame build={section("TOAST", "Toast.ast
ro")} />
   <Pre>{" "}</Pre>
   <Pre><span id="toast-ok"><Button label="Q
UEUE" variant="primary" /></span>{"  "}<span
 id="toast-error"><Button label="JAM" /></sp
an>{"  "}<span id="toast-pending"><Button la
bel="HOLD" /></span></Pre>
 
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET