OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
HASH      607772a649b9
DATE      2026-07-17
SUBJECT   web: form frame, display components, nav buttons
FILES     23 CHANGED
HASH      607772a649b9
DATE      2026-07-17
SUBJECT   web: form frame, display components,
          nav buttons
FILES     23 CHANGED
 

diff --git a/ooknet-design/src/components/Button/Button.astro b/ooknet-design/src/
components/Button/Button.astro
index f6ac8a9..556296c 100644
--- a/ooknet-design/src/components/Button/Button.astro
+++ b/ooknet-design/src/components/Button/Button.astro
@@ -2,8 +2,8 @@
 import type { Props } from "./Button";
 import "./Button.scss";
 
-const { label, href, type = "button", variant = "default", disabled = false } = A
stro.props as Props;
+const { label, href, type = "button", variant = "default", disabled = false, curr
ent = false } = Astro.props as Props;
 ---
 {href && !disabled
-  ? <a href={href} class:list={["btn", variant]}>[ {label} ]</a>
+  ? <a href={href} class:list={["btn", variant]} aria-current={current ? "page" :
 undefined}>[ {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
index f6ac8a9..556296c 100644
--- a/ooknet-design/src/components/Button/Bu
tton.astro
+++ b/ooknet-design/src/components/Button/Bu
tton.astro
@@ -2,8 +2,8 @@
 import type { Props } from "./Button";
 import "./Button.scss";
 
-const { label, href, type = "button", varia
nt = "default", disabled = false } = Astro.p
rops as Props;
+const { label, href, type = "button", varia
nt = "default", disabled = false, current = 
false } = Astro.props as Props;
 ---
 {href && !disabled
-  ? <a href={href} class:list={["btn", vari
ant]}>[ {label} ]</a>
+  ? <a href={href} class:list={["btn", vari
ant]} aria-current={current ? "page" : undef
ined}>[ {label} ]</a>
   : <button type={type} class:list={["btn",
 variant]} disabled={disabled}>[ {label} ]</
button>}
 

diff --git a/ooknet-design/src/components/Button/Button.ts b/ooknet-design/src/com
ponents/Button/Button.ts
index b2adbc9..7f9650b 100644
--- a/ooknet-design/src/components/Button/Button.ts
+++ b/ooknet-design/src/components/Button/Button.ts
@@ -3,9 +3,11 @@ import { len } from "../../lib/ascii";
 export interface Props {
   label: string;
   href?: string;
-  type?: "button" | "submit";
+  type?: "button" | "submit" | "reset";
   variant?: "default" | "primary";
   disabled?: boolean;
+  /** Marks a link as the current page (aria-current). */
+  current?: boolean;
 }
 
 // "[ " + label + " ]"

diff --git a/ooknet-design/src/components/Bu
tton/Button.ts b/ooknet-design/src/component
s/Button/Button.ts
index b2adbc9..7f9650b 100644
--- a/ooknet-design/src/components/Button/Bu
tton.ts
+++ b/ooknet-design/src/components/Button/Bu
tton.ts
@@ -3,9 +3,11 @@ import { len } from "../../
lib/ascii";
 export interface Props {
   label: string;
   href?: string;
-  type?: "button" | "submit";
+  type?: "button" | "submit" | "reset";
   variant?: "default" | "primary";
   disabled?: boolean;
+  /** Marks a link as the current page (ari
a-current). */
+  current?: boolean;
 }
 
 // "[ " + label + " ]"
 

diff --git a/ooknet-design/src/components/FormFrame/FormFrame.astro b/ooknet-desig
n/src/components/FormFrame/FormFrame.astro
new file mode 100644
index 0000000..abadfc8
--- /dev/null
+++ b/ooknet-design/src/components/FormFrame/FormFrame.astro
@@ -0,0 +1,87 @@
+---
+import Pre from "../Pre/Pre.astro";
+import TextField from "../TextField/TextField.astro";
+import Select from "../Select/Select.astro";
+import { selectWidth } from "../Select/Select";
+import Radio from "../Radio/Radio.astro";
+import { radioWidth } from "../Radio/Radio";
+import Checkbox from "../Checkbox/Checkbox.astro";
+import { checkboxWidth } from "../Checkbox/Checkbox";
+import Button from "../Button/Button.astro";
+import { buttonWidth } from "../Button/Button";
+import { G, checkGrid, len, pad } from "../../lib/ascii";
+import { MOBILE_FRAME_W } from "../../lib/config";
+import { formGeometry, FORM_LABEL_W, FORM_INDENT, type Props } from "./FormFrame"
;
+import "./FormFrame.scss";
+
+const { title, fields, width = MOBILE_FRAME_W, action, method } = Astro.props as 
Props;
+const { inner, contentW, defaultCells, textareaCells } = formGeometry(width);
+
+const ctx = `FormFrame(${title})`;
+const open = `${G.V}${" ".repeat(FORM_INDENT)}`;
+const close = (used: number) => `${" ".repeat(Math.max(0, contentW - used))}${G.V
}`;
+const lbl = (s: string) => pad(s, FORM_LABEL_W);
+
+const top = checkGrid(
+  `${G.TL}${G.H} ${title} ${G.H.repeat(Math.max(0, inner - 3 - len(title)))}${G.T
R}`,
+  width,
+  ctx,
+);
+const blank = `${G.V}${" ".repeat(inner)}${G.V}`;
+const bottom = `${G.BL}${G.H.repeat(inner)}${G.BR}`;
+---
+<form class="form-frame" action={action} method={method}>
+  <Pre>{top}</Pre>
+  <Pre>{blank}</Pre>
+  {fields.map((f) => {
+    if (f.kind === "gap") return <Pre>{blank}</Pre>;
+
+    if (f.kind === "text" || f.kind === "password") {
+      const cells = f.cells ?? defaultCells;
+      return <Pre>{open}{lbl(f.label)}<TextField name={f.name} cells={cells} type
={f.kind} />{close(FORM_LABEL_W + cells)}</Pre>;
+    }
+
+    if (f.kind === "select") {
+      const cells = f.cells ?? defaultCells - 6;
+      return <Pre>{open}{lbl(f.label)}<Select name={f.name} options={f.options} c
ells={cells} value={f.value} />{close(FORM_LABEL_W + selectWidth(cells))}</Pre>;
+    }
+
+    if (f.kind === "radios") {
+      return f.options.map((opt, i) => (
+        <Pre>{open}{i === 0 ? lbl(f.label) : " ".repeat(FORM_LABEL_W)}<Radio labe
l={opt} name={f.name} value={opt.toLowerCase()} checked={f.value === opt} />{close
(FORM_LABEL_W + radioWidth(opt))}</Pre>
+      ));
+    }
+
+    if (f.kind === "checkbox") {
+      return <Pre>{open}<Checkbox label={f.label} name={f.name} checked={f.checke
d} />{close(checkboxWidth(f.label))}</Pre>;
+    }
+
+    if (f.kind === "textarea") {
+      const rows = f.rows ?? 4;
+      return (
+        <Fragment>
+          <Pre>{open}{checkGrid(f.label, contentW, ctx)}{close(len(f.label))}</Pr
e>
+          <div class="form-textarea">
+            {Array.from({ length: rows }, () => (
+              <Pre>{blank}</Pre>
+            ))}
+            <textarea
+              name={f.name}
+              rows={rows}
+              spellcheck="false"
+              aria-label={f.label}
+              style={`left: calc(var(--cell-w) * ${1 + FORM_INDENT}); width: calc
(var(--cell-w) * ${textareaCells}); height: calc(var(--cell-h) * ${rows})`}
+            ></textarea>
+          </div>
+        </Fragment>
+      );
+    }
+
+    const used = f.buttons.reduce((s, b, i) => s + buttonWidth(b.label) + (i ? 2 
: 0), 0);
+    return <Pre>{open}{f.buttons.map((b, i) => (
+      <Fragment>{i > 0 ? "  " : ""}<Button label={b.label} type={b.type ?? "butto
n"} variant={b.variant} /></Fragment>
+    ))}{close(used)}</Pre>;
+  })}
+  <Pre>{blank}</Pre>
+  <Pre>{bottom}</Pre>
+</form>

diff --git a/ooknet-design/src/components/Fo
rmFrame/FormFrame.astro b/ooknet-design/src/
components/FormFrame/FormFrame.astro
new file mode 100644
index 0000000..abadfc8
--- /dev/null
+++ b/ooknet-design/src/components/FormFrame
/FormFrame.astro
@@ -0,0 +1,87 @@
+---
+import Pre from "../Pre/Pre.astro";
+import TextField from "../TextField/TextFie
ld.astro";
+import Select from "../Select/Select.astro"
;
+import { selectWidth } from "../Select/Sele
ct";
+import Radio from "../Radio/Radio.astro";
+import { radioWidth } from "../Radio/Radio"
;
+import Checkbox from "../Checkbox/Checkbox.
astro";
+import { checkboxWidth } from "../Checkbox/
Checkbox";
+import Button from "../Button/Button.astro"
;
+import { buttonWidth } from "../Button/Butt
on";
+import { G, checkGrid, len, pad } from "../
../lib/ascii";
+import { MOBILE_FRAME_W } from "../../lib/c
onfig";
+import { formGeometry, FORM_LABEL_W, FORM_I
NDENT, type Props } from "./FormFrame";
+import "./FormFrame.scss";
+
+const { title, fields, width = MOBILE_FRAME
_W, action, method } = Astro.props as Props;
+const { inner, contentW, defaultCells, text
areaCells } = formGeometry(width);
+
+const ctx = `FormFrame(${title})`;
+const open = `${G.V}${" ".repeat(FORM_INDEN
T)}`;
+const close = (used: number) => `${" ".repe
at(Math.max(0, contentW - used))}${G.V}`;
+const lbl = (s: string) => pad(s, FORM_LABE
L_W);
+
+const top = checkGrid(
+  `${G.TL}${G.H} ${title} ${G.H.repeat(Math
.max(0, inner - 3 - len(title)))}${G.TR}`,
+  width,
+  ctx,
+);
+const blank = `${G.V}${" ".repeat(inner)}${
G.V}`;
+const bottom = `${G.BL}${G.H.repeat(inner)}
${G.BR}`;
+---
+<form class="form-frame" action={action} me
thod={method}>
+  <Pre>{top}</Pre>
+  <Pre>{blank}</Pre>
+  {fields.map((f) => {
+    if (f.kind === "gap") return <Pre>{blan
k}</Pre>;
+
+    if (f.kind === "text" || f.kind === "pa
ssword") {
+      const cells = f.cells ?? defaultCells
;
+      return <Pre>{open}{lbl(f.label)}<Text
Field name={f.name} cells={cells} type={f.ki
nd} />{close(FORM_LABEL_W + cells)}</Pre>;
+    }
+
+    if (f.kind === "select") {
+      const cells = f.cells ?? defaultCells
 - 6;
+      return <Pre>{open}{lbl(f.label)}<Sele
ct name={f.name} options={f.options} cells={
cells} value={f.value} />{close(FORM_LABEL_W
 + selectWidth(cells))}</Pre>;
+    }
+
+    if (f.kind === "radios") {
+      return f.options.map((opt, i) => (
+        <Pre>{open}{i === 0 ? lbl(f.label) 
: " ".repeat(FORM_LABEL_W)}<Radio label={opt
} name={f.name} value={opt.toLowerCase()} ch
ecked={f.value === opt} />{close(FORM_LABEL_
W + radioWidth(opt))}</Pre>
+      ));
+    }
+
+    if (f.kind === "checkbox") {
+      return <Pre>{open}<Checkbox label={f.
label} name={f.name} checked={f.checked} />{
close(checkboxWidth(f.label))}</Pre>;
+    }
+
+    if (f.kind === "textarea") {
+      const rows = f.rows ?? 4;
+      return (
+        <Fragment>
+          <Pre>{open}{checkGrid(f.label, co
ntentW, ctx)}{close(len(f.label))}</Pre>
+          <div class="form-textarea">
+            {Array.from({ length: rows }, (
) => (
+              <Pre>{blank}</Pre>
+            ))}
+            <textarea
+              name={f.name}
+              rows={rows}
+              spellcheck="false"
+              aria-label={f.label}
+              style={`left: calc(var(--cell
-w) * ${1 + FORM_INDENT}); width: calc(var(-
-cell-w) * ${textareaCells}); height: calc(v
ar(--cell-h) * ${rows})`}
+            ></textarea>
+          </div>
+        </Fragment>
+      );
+    }
+
+    const used = f.buttons.reduce((s, b, i)
 => s + buttonWidth(b.label) + (i ? 2 : 0), 
0);
+    return <Pre>{open}{f.buttons.map((b, i)
 => (
+      <Fragment>{i > 0 ? "  " : ""}<Button 
label={b.label} type={b.type ?? "button"} va
riant={b.variant} /></Fragment>
+    ))}{close(used)}</Pre>;
+  })}
+  <Pre>{blank}</Pre>
+  <Pre>{bottom}</Pre>
+</form>
 

diff --git a/ooknet-design/src/components/FormFrame/FormFrame.scss b/ooknet-design
/src/components/FormFrame/FormFrame.scss
new file mode 100644
index 0000000..00e5694
--- /dev/null
+++ b/ooknet-design/src/components/FormFrame/FormFrame.scss
@@ -0,0 +1,38 @@
+.form-frame {
+  margin: 0;
+}
+
+// The textarea floats over pre-rendered blank frame rows so the box
+// borders stay build-time strings; offsets are set inline in cells.
+.form-textarea {
+  position: relative;
+
+  textarea {
+    position: absolute;
+    top: 0;
+    font-family: var(--mono-frame);
+    font-size: var(--fs);
+    line-height: var(--lh);
+    color: inherit;
+    background: transparent;
+    border: 0;
+    outline: 0;
+    padding: 0;
+    margin: 0;
+    resize: none;
+    overflow: auto;
+    caret-color: currentColor;
+    // match pre.ascii rendering so the weight reads the same
+    -webkit-font-smoothing: antialiased;
+
+    &::placeholder {
+      color: var(--ink-soft);
+      opacity: 1;
+    }
+
+    &:focus-visible {
+      outline: 1px dashed currentColor;
+      outline-offset: 2px;
+    }
+  }
+}

diff --git a/ooknet-design/src/components/Fo
rmFrame/FormFrame.scss b/ooknet-design/src/c
omponents/FormFrame/FormFrame.scss
new file mode 100644
index 0000000..00e5694
--- /dev/null
+++ b/ooknet-design/src/components/FormFrame
/FormFrame.scss
@@ -0,0 +1,38 @@
+.form-frame {
+  margin: 0;
+}
+
+// The textarea floats over pre-rendered bl
ank frame rows so the box
+// borders stay build-time strings; offsets
 are set inline in cells.
+.form-textarea {
+  position: relative;
+
+  textarea {
+    position: absolute;
+    top: 0;
+    font-family: var(--mono-frame);
+    font-size: var(--fs);
+    line-height: var(--lh);
+    color: inherit;
+    background: transparent;
+    border: 0;
+    outline: 0;
+    padding: 0;
+    margin: 0;
+    resize: none;
+    overflow: auto;
+    caret-color: currentColor;
+    // match pre.ascii rendering so the wei
ght reads the same
+    -webkit-font-smoothing: antialiased;
+
+    &::placeholder {
+      color: var(--ink-soft);
+      opacity: 1;
+    }
+
+    &:focus-visible {
+      outline: 1px dashed currentColor;
+      outline-offset: 2px;
+    }
+  }
+}
 

diff --git a/ooknet-design/src/components/FormFrame/FormFrame.ts b/ooknet-design/s
rc/components/FormFrame/FormFrame.ts
new file mode 100644
index 0000000..1d760e0
--- /dev/null
+++ b/ooknet-design/src/components/FormFrame/FormFrame.ts
@@ -0,0 +1,42 @@
+import type { SelectOption } from "../Select/Select";
+
+export type ButtonSpec = {
+  label: string;
+  type?: "button" | "submit" | "reset";
+  variant?: "default" | "primary";
+};
+
+export type Field =
+  | { kind: "text" | "password"; label: string; name: string; cells?: number }
+  | { kind: "textarea"; label: string; name: string; rows?: number }
+  | { kind: "select"; label: string; name: string; options: (SelectOption | strin
g)[]; cells?: number; value?: string }
+  | { kind: "radios"; label: string; name: string; options: string[]; value?: str
ing }
+  | { kind: "checkbox"; label: string; name: string; checked?: boolean }
+  | { kind: "buttons"; buttons: ButtonSpec[] }
+  | { kind: "gap" };
+
+export interface Props {
+  title: string;
+  fields: Field[];
+  /** Box width in cells. Defaults to the narrow frame so one render
+   *  serves both viewports (inputs can't dual-render). */
+  width?: number;
+  action?: string;
+  method?: string;
+}
+
+export const FORM_LABEL_W = 12;
+export const FORM_INDENT = 2;
+
+export function formGeometry(width: number) {
+  const inner = width - 2;
+  const contentW = inner - FORM_INDENT;
+  return {
+    inner,
+    contentW,
+    /** Default input width: fills the row after the label column. */
+    defaultCells: contentW - FORM_LABEL_W,
+    /** Textarea spans the content area minus a right-hand indent. */
+    textareaCells: contentW - FORM_INDENT,
+  };
+}

diff --git a/ooknet-design/src/components/Fo
rmFrame/FormFrame.ts b/ooknet-design/src/com
ponents/FormFrame/FormFrame.ts
new file mode 100644
index 0000000..1d760e0
--- /dev/null
+++ b/ooknet-design/src/components/FormFrame
/FormFrame.ts
@@ -0,0 +1,42 @@
+import type { SelectOption } from "../Selec
t/Select";
+
+export type ButtonSpec = {
+  label: string;
+  type?: "button" | "submit" | "reset";
+  variant?: "default" | "primary";
+};
+
+export type Field =
+  | { kind: "text" | "password"; label: str
ing; name: string; cells?: number }
+  | { kind: "textarea"; label: string; name
: string; rows?: number }
+  | { kind: "select"; label: string; name: 
string; options: (SelectOption | string)[]; 
cells?: number; value?: string }
+  | { kind: "radios"; label: string; name: 
string; options: string[]; value?: string }
+  | { kind: "checkbox"; label: string; name
: string; checked?: boolean }
+  | { kind: "buttons"; buttons: ButtonSpec[
] }
+  | { kind: "gap" };
+
+export interface Props {
+  title: string;
+  fields: Field[];
+  /** Box width in cells. Defaults to the n
arrow frame so one render
+   *  serves both viewports (inputs can't d
ual-render). */
+  width?: number;
+  action?: string;
+  method?: string;
+}
+
+export const FORM_LABEL_W = 12;
+export const FORM_INDENT = 2;
+
+export function formGeometry(width: number)
 {
+  const inner = width - 2;
+  const contentW = inner - FORM_INDENT;
+  return {
+    inner,
+    contentW,
+    /** Default input width: fills the row 
after the label column. */
+    defaultCells: contentW - FORM_LABEL_W,
+    /** Textarea spans the content area min
us a right-hand indent. */
+    textareaCells: contentW - FORM_INDENT,
+  };
+}
 

diff --git a/ooknet-design/src/components/NavBar/NavBar.astro b/ooknet-design/src/
components/NavBar/NavBar.astro
index 6517d0d..8382305 100644
--- a/ooknet-design/src/components/NavBar/NavBar.astro
+++ b/ooknet-design/src/components/NavBar/NavBar.astro
@@ -1,6 +1,6 @@
 ---
 import Pre from "../Pre/Pre.astro";
-import NavButton from "../NavButton/NavButton.astro";
+import Button from "../Button/Button.astro";
 import NavLanguage from "../NavLanguage/NavLanguage.astro";
 import { FRAME_W } from "../../lib/config";
 import { NAV_ITEMS, NAV_GAP, NAV_LANG_W, isActive, itemsWidth, type Props } from 
"./NavBar";
@@ -17,4 +17,4 @@ const padW = Math.max(1, width - itemsWidth(items, gap) - langW)
;
 const padStr = " ".repeat(padW);
 const gapStr = " ".repeat(gap);
 ---
-<Pre class="nav-bar">{items.map((it, i) => (<Fragment>{i > 0 ? gapStr : ""}<NavBu
tton label={it.label} href={it.href} active={isActive(it.href, path)} /></Fragment
>))}{padStr}{lang && <NavLanguage />}</Pre>
+<Pre class="nav-bar">{items.map((it, i) => (<Fragment>{i > 0 ? gapStr : ""}<Butto
n label={it.label} href={it.href} variant={isActive(it.href, path) ? "primary" : "
default"} current={isActive(it.href, path)} /></Fragment>))}{padStr}{lang && <NavL
anguage />}</Pre>

diff --git a/ooknet-design/src/components/Na
vBar/NavBar.astro b/ooknet-design/src/compon
ents/NavBar/NavBar.astro
index 6517d0d..8382305 100644
--- a/ooknet-design/src/components/NavBar/Na
vBar.astro
+++ b/ooknet-design/src/components/NavBar/Na
vBar.astro
@@ -1,6 +1,6 @@
 ---
 import Pre from "../Pre/Pre.astro";
-import NavButton from "../NavButton/NavButt
on.astro";
+import Button from "../Button/Button.astro"
;
 import NavLanguage from "../NavLanguage/Nav
Language.astro";
 import { FRAME_W } from "../../lib/config";
 import { NAV_ITEMS, NAV_GAP, NAV_LANG_W, is
Active, itemsWidth, type Props } from "./Nav
Bar";
@@ -17,4 +17,4 @@ const padW = Math.max(1, w
idth - itemsWidth(items, gap) - langW);
 const padStr = " ".repeat(padW);
 const gapStr = " ".repeat(gap);
 ---
-<Pre class="nav-bar">{items.map((it, i) => 
(<Fragment>{i > 0 ? gapStr : ""}<NavButton l
abel={it.label} href={it.href} active={isAct
ive(it.href, path)} /></Fragment>))}{padStr}
{lang && <NavLanguage />}</Pre>
+<Pre class="nav-bar">{items.map((it, i) => 
(<Fragment>{i > 0 ? gapStr : ""}<Button labe
l={it.label} href={it.href} variant={isActiv
e(it.href, path) ? "primary" : "default"} cu
rrent={isActive(it.href, path)} /></Fragment
>))}{padStr}{lang && <NavLanguage />}</Pre>
 

diff --git a/ooknet-design/src/components/NavBar/NavBar.ts b/ooknet-design/src/com
ponents/NavBar/NavBar.ts
index b3824fe..98c9f7c 100644
--- a/ooknet-design/src/components/NavBar/NavBar.ts
+++ b/ooknet-design/src/components/NavBar/NavBar.ts
@@ -1,6 +1,6 @@
-// Nav row data and width math. Each button renders as a 2-char prefix
-// ("? " when active, two spaces otherwise) plus its label, so row width
-// doesn't change with the active item.
+// Nav row data and width math. Items render as [ LABEL ] buttons, so
+// each is buttonWidth(label) cells regardless of active state.
+import { buttonWidth } from "../Button/Button";
 
 export interface NavItem {
   label: string;
@@ -11,7 +11,7 @@ export interface Props {
   width?: number;
   items?: NavItem[];
   /** Spaces between buttons. Defaults to NAV_GAP (4); narrower frames
-   *  pass 2 to fit four labels in 48 cells. */
+   *  pass 1 to fit four bracketed labels in 48 cells. */
   gap?: number;
   /** Whether to render the EN label and reserve space for it. */
   lang?: boolean;
@@ -27,9 +27,6 @@ export const NAV_ITEMS: NavItem[] = [
 export const NAV_GAP = 4;       // spaces between buttons
 export const NAV_LANG_W = 2;    // visible width of "EN"
 
-/** Width of one nav button as rendered (2-char prefix + label). */
-export const buttonWidth = (label: string) => 2 + label.length;
-
 /** Total width of all buttons + inter-button gaps. */
 export function itemsWidth(items: NavItem[], gap = NAV_GAP): number {
   return items.reduce(

diff --git a/ooknet-design/src/components/Na
vBar/NavBar.ts b/ooknet-design/src/component
s/NavBar/NavBar.ts
index b3824fe..98c9f7c 100644
--- a/ooknet-design/src/components/NavBar/Na
vBar.ts
+++ b/ooknet-design/src/components/NavBar/Na
vBar.ts
@@ -1,6 +1,6 @@
-// Nav row data and width math. Each button
 renders as a 2-char prefix
-// ("? " when active, two spaces otherwise)
 plus its label, so row width
-// doesn't change with the active item.
+// Nav row data and width math. Items rende
r as [ LABEL ] buttons, so
+// each is buttonWidth(label) cells regardl
ess of active state.
+import { buttonWidth } from "../Button/Butt
on";
 
 export interface NavItem {
   label: string;
@@ -11,7 +11,7 @@ export interface Props {
   width?: number;
   items?: NavItem[];
   /** Spaces between buttons. Defaults to N
AV_GAP (4); narrower frames
-   *  pass 2 to fit four labels in 48 cells
. */
+   *  pass 1 to fit four bracketed labels i
n 48 cells. */
   gap?: number;
   /** Whether to render the EN label and re
serve space for it. */
   lang?: boolean;
@@ -27,9 +27,6 @@ export const NAV_ITEMS: Na
vItem[] = [
 export const NAV_GAP = 4;       // spaces b
etween buttons
 export const NAV_LANG_W = 2;    // visible 
width of "EN"
 
-/** Width of one nav button as rendered (2-
char prefix + label). */
-export const buttonWidth = (label: string) 
=> 2 + label.length;
-
 /** Total width of all buttons + inter-butt
on gaps. */
 export function itemsWidth(items: NavItem[]
, gap = NAV_GAP): number {
   return items.reduce(
 

diff --git a/ooknet-design/src/components/NavButton/NavButton.astro b/ooknet-desig
n/src/components/NavButton/NavButton.astro
deleted file mode 100644
index 062f0e4..0000000
--- a/ooknet-design/src/components/NavButton/NavButton.astro
+++ /dev/null
@@ -1,8 +0,0 @@
----
-import "./NavButton.scss";
-import type { Props } from "./NavButton";
-
-const { label, href, active = false } = Astro.props as Props;
-const prefix = active ? "? " : "  ";
----
-<a href={href} class:list={["nav-button", { active }]}>{prefix}{label}</a>

diff --git a/ooknet-design/src/components/Na
vButton/NavButton.astro b/ooknet-design/src/
components/NavButton/NavButton.astro
deleted file mode 100644
index 062f0e4..0000000
--- a/ooknet-design/src/components/NavButton
/NavButton.astro
+++ /dev/null
@@ -1,8 +0,0 @@
----
-import "./NavButton.scss";
-import type { Props } from "./NavButton";
-
-const { label, href, active = false } = Ast
ro.props as Props;
-const prefix = active ? "? " : "  ";
----
-<a href={href} class:list={["nav-button", {
 active }]}>{prefix}{label}</a>
 

diff --git a/ooknet-design/src/components/NavButton/NavButton.ts b/ooknet-design/s
rc/components/NavButton/NavButton.ts
deleted file mode 100644
index 383854b..0000000
--- a/ooknet-design/src/components/NavButton/NavButton.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export interface Props {
-  label: string;
-  href: string;
-  active?: boolean;
-}

diff --git a/ooknet-design/src/components/Na
vButton/NavButton.ts b/ooknet-design/src/com
ponents/NavButton/NavButton.ts
deleted file mode 100644
index 383854b..0000000
--- a/ooknet-design/src/components/NavButton
/NavButton.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export interface Props {
-  label: string;
-  href: string;
-  active?: boolean;
-}
 

diff --git a/ooknet-design/src/components/Progress/Progress.astro b/ooknet-design/
src/components/Progress/Progress.astro
new file mode 100644
index 0000000..8103a3a
--- /dev/null
+++ b/ooknet-design/src/components/Progress/Progress.astro
@@ -0,0 +1,6 @@
+---
+import { buildProgress, type Props } from "./Progress";
+
+const { value, cells = 20 } = Astro.props as Props;
+---
+<span class="progress" role="progressbar" aria-valuenow={Math.round(value)} aria-
valuemin="0" aria-valuemax="100">{buildProgress(value, cells)}</span>

diff --git a/ooknet-design/src/components/Pr
ogress/Progress.astro b/ooknet-design/src/co
mponents/Progress/Progress.astro
new file mode 100644
index 0000000..8103a3a
--- /dev/null
+++ b/ooknet-design/src/components/Progress/
Progress.astro
@@ -0,0 +1,6 @@
+---
+import { buildProgress, type Props } from "
./Progress";
+
+const { value, cells = 20 } = Astro.props a
s Props;
+---
+<span class="progress" role="progressbar" a
ria-valuenow={Math.round(value)} aria-valuem
in="0" aria-valuemax="100">{buildProgress(va
lue, cells)}</span>
 

diff --git a/ooknet-design/src/components/Progress/Progress.test.ts b/ooknet-desig
n/src/components/Progress/Progress.test.ts
new file mode 100644
index 0000000..92bf071
--- /dev/null
+++ b/ooknet-design/src/components/Progress/Progress.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import { buildProgress, progressWidth } from "./Progress";
+import { len } from "../../lib/ascii";
+
+describe("buildProgress", () => {
+  it("renders the exact declared width at any value", () => {
+    for (const v of [0, 8, 50, 99, 100]) {
+      expect(len(buildProgress(v, 20))).toBe(progressWidth(20));
+    }
+  });
+
+  it("fills proportionally", () => {
+    expect(buildProgress(0, 10)).toBe("[░░░░░░░░░░]   0%");
+    expect(buildProgress(50, 10)).toBe("[█████░░░░░]  50%");
+    expect(buildProgress(100, 10)).toBe("[██████████] 100%");
+  });
+
+  it("clamps out-of-range values", () => {
+    expect(buildProgress(-5, 10)).toBe(buildProgress(0, 10));
+    expect(buildProgress(250, 10)).toBe(buildProgress(100, 10));
+  });
+});

diff --git a/ooknet-design/src/components/Pr
ogress/Progress.test.ts b/ooknet-design/src/
components/Progress/Progress.test.ts
new file mode 100644
index 0000000..92bf071
--- /dev/null
+++ b/ooknet-design/src/components/Progress/
Progress.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vites
t";
+import { buildProgress, progressWidth } fro
m "./Progress";
+import { len } from "../../lib/ascii";
+
+describe("buildProgress", () => {
+  it("renders the exact declared width at a
ny value", () => {
+    for (const v of [0, 8, 50, 99, 100]) {
+      expect(len(buildProgress(v, 20))).toB
e(progressWidth(20));
+    }
+  });
+
+  it("fills proportionally", () => {
+    expect(buildProgress(0, 10)).toBe("[░░░
░░░░░░░]   0%");
+    expect(buildProgress(50, 10)).toBe("[██
███░░░░░]  50%");
+    expect(buildProgress(100, 10)).toBe("[█
█████████] 100%");
+  });
+
+  it("clamps out-of-range values", () => {
+    expect(buildProgress(-5, 10)).toBe(buil
dProgress(0, 10));
+    expect(buildProgress(250, 10)).toBe(bui
ldProgress(100, 10));
+  });
+});
 

diff --git a/ooknet-design/src/components/Progress/Progress.ts b/ooknet-design/src
/components/Progress/Progress.ts
new file mode 100644
index 0000000..45aba15
--- /dev/null
+++ b/ooknet-design/src/components/Progress/Progress.ts
@@ -0,0 +1,15 @@
+export interface Props {
+  /** 0-100. */
+  value: number;
+  /** Bar width in cells, excluding brackets and the percentage. */
+  cells?: number;
+}
+
+export function buildProgress(value: number, cells = 20): string {
+  const v = Math.max(0, Math.min(100, Math.round(value)));
+  const filled = Math.round((v / 100) * cells);
+  return `[${"█".repeat(filled)}${"░".repeat(cells - filled)}] ${String(v).padSta
rt(3)}%`;
+}
+
+// "[" + cells + "] 100%"
+export const progressWidth = (cells = 20) => cells + 7;

diff --git a/ooknet-design/src/components/Pr
ogress/Progress.ts b/ooknet-design/src/compo
nents/Progress/Progress.ts
new file mode 100644
index 0000000..45aba15
--- /dev/null
+++ b/ooknet-design/src/components/Progress/
Progress.ts
@@ -0,0 +1,15 @@
+export interface Props {
+  /** 0-100. */
+  value: number;
+  /** Bar width in cells, excluding bracket
s and the percentage. */
+  cells?: number;
+}
+
+export function buildProgress(value: number
, cells = 20): string {
+  const v = Math.max(0, Math.min(100, Math.
round(value)));
+  const filled = Math.round((v / 100) * cel
ls);
+  return `[${"█".repeat(filled)}${"░".repea
t(cells - filled)}] ${String(v).padStart(3)}
%`;
+}
+
+// "[" + cells + "] 100%"
+export const progressWidth = (cells = 20) =
> cells + 7;
 

diff --git a/ooknet-design/src/components/Spinner/Spinner.astro b/ooknet-design/sr
c/components/Spinner/Spinner.astro
new file mode 100644
index 0000000..13f8f31
--- /dev/null
+++ b/ooknet-design/src/components/Spinner/Spinner.astro
@@ -0,0 +1,14 @@
+---
+// Cycles | / - \ in place. Astro dedupes the script across instances,
+// so every [data-spinner] on the page ticks from one interval.
+---
+<span class="spinner" data-spinner aria-hidden="true">|</span>
+
+<script>
+  const FRAMES = "|/-\\";
+  let i = 0;
+  setInterval(() => {
+    i = (i + 1) % FRAMES.length;
+    for (const el of document.querySelectorAll("[data-spinner]")) el.textContent 
= FRAMES[i];
+  }, 150);
+</script>

diff --git a/ooknet-design/src/components/Sp
inner/Spinner.astro b/ooknet-design/src/comp
onents/Spinner/Spinner.astro
new file mode 100644
index 0000000..13f8f31
--- /dev/null
+++ b/ooknet-design/src/components/Spinner/S
pinner.astro
@@ -0,0 +1,14 @@
+---
+// Cycles | / - \ in place. Astro dedupes t
he script across instances,
+// so every [data-spinner] on the page tick
s from one interval.
+---
+<span class="spinner" data-spinner aria-hid
den="true">|</span>
+
+<script>
+  const FRAMES = "|/-\\";
+  let i = 0;
+  setInterval(() => {
+    i = (i + 1) % FRAMES.length;
+    for (const el of document.querySelector
All("[data-spinner]")) el.textContent = FRAM
ES[i];
+  }, 150);
+</script>
 

diff --git a/ooknet-design/src/components/Status/Status.astro b/ooknet-design/src/
components/Status/Status.astro
new file mode 100644
index 0000000..328c91b
--- /dev/null
+++ b/ooknet-design/src/components/Status/Status.astro
@@ -0,0 +1,7 @@
+---
+import { STATUS_TAG, type Props } from "./Status";
+import "./Status.scss";
+
+const { message, variant = "ok" } = Astro.props as Props;
+---
+<span class:list={["status", `status-${variant}`]} role="status">{STATUS_TAG[vari
ant]}{" " + message}</span>

diff --git a/ooknet-design/src/components/St
atus/Status.astro b/ooknet-design/src/compon
ents/Status/Status.astro
new file mode 100644
index 0000000..328c91b
--- /dev/null
+++ b/ooknet-design/src/components/Status/St
atus.astro
@@ -0,0 +1,7 @@
+---
+import { STATUS_TAG, type Props } from "./S
tatus";
+import "./Status.scss";
+
+const { message, variant = "ok" } = Astro.p
rops as Props;
+---
+<span class:list={["status", `status-${vari
ant}`]} role="status">{STATUS_TAG[variant]}{
" " + message}</span>
 

diff --git a/ooknet-design/src/components/Status/Status.scss b/ooknet-design/src/c
omponents/Status/Status.scss
new file mode 100644
index 0000000..7bec491
--- /dev/null
+++ b/ooknet-design/src/components/Status/Status.scss
@@ -0,0 +1,11 @@
+.status {
+  white-space: pre;
+
+  &.status-error {
+    font-weight: 700;
+  }
+
+  &.status-pending {
+    color: var(--ink-soft);
+  }
+}

diff --git a/ooknet-design/src/components/St
atus/Status.scss b/ooknet-design/src/compone
nts/Status/Status.scss
new file mode 100644
index 0000000..7bec491
--- /dev/null
+++ b/ooknet-design/src/components/Status/St
atus.scss
@@ -0,0 +1,11 @@
+.status {
+  white-space: pre;
+
+  &.status-error {
+    font-weight: 700;
+  }
+
+  &.status-pending {
+    color: var(--ink-soft);
+  }
+}
 

diff --git a/ooknet-design/src/components/Status/Status.ts b/ooknet-design/src/com
ponents/Status/Status.ts
new file mode 100644
index 0000000..45a74a5
--- /dev/null
+++ b/ooknet-design/src/components/Status/Status.ts
@@ -0,0 +1,12 @@
+export type StatusVariant = "ok" | "error" | "pending";
+
+export interface Props {
+  message: string;
+  variant?: StatusVariant;
+}
+
+export const STATUS_TAG: Record<StatusVariant, string> = {
+  ok: "[ OK ]",
+  error: "[ !! ]",
+  pending: "[ .. ]",
+};

diff --git a/ooknet-design/src/components/St
atus/Status.ts b/ooknet-design/src/component
s/Status/Status.ts
new file mode 100644
index 0000000..45a74a5
--- /dev/null
+++ b/ooknet-design/src/components/Status/St
atus.ts
@@ -0,0 +1,12 @@
+export type StatusVariant = "ok" | "error" 
| "pending";
+
+export interface Props {
+  message: string;
+  variant?: StatusVariant;
+}
+
+export const STATUS_TAG: Record<StatusVaria
nt, string> = {
+  ok: "[ OK ]",
+  error: "[ !! ]",
+  pending: "[ .. ]",
+};
 

diff --git a/ooknet-design/src/components/TopHeader/TopHeader.astro b/ooknet-desig
n/src/components/TopHeader/TopHeader.astro
index 1bb8d38..1d7ecc2 100644
--- a/ooknet-design/src/components/TopHeader/TopHeader.astro
+++ b/ooknet-design/src/components/TopHeader/TopHeader.astro
@@ -34,7 +34,7 @@ const PLACEHOLDER_NARROW = "_".repeat(18);
     <ul class="masthead-search-results" hidden data-search-results></ul>
   </div>
   <Pre>{rule(MOBILE_FRAME_W)}</Pre>
-  <NavBar width={MOBILE_FRAME_W} gap={2} lang={false} />
+  <NavBar width={MOBILE_FRAME_W} gap={1} lang={false} />
   <Pre>{ruleD(MOBILE_FRAME_W)}</Pre>
 </div>
 

diff --git a/ooknet-design/src/components/To
pHeader/TopHeader.astro b/ooknet-design/src/
components/TopHeader/TopHeader.astro
index 1bb8d38..1d7ecc2 100644
--- a/ooknet-design/src/components/TopHeader
/TopHeader.astro
+++ b/ooknet-design/src/components/TopHeader
/TopHeader.astro
@@ -34,7 +34,7 @@ const PLACEHOLDER_NARROW =
 "_".repeat(18);
     <ul class="masthead-search-results" hid
den data-search-results></ul>
   </div>
   <Pre>{rule(MOBILE_FRAME_W)}</Pre>
-  <NavBar width={MOBILE_FRAME_W} gap={2} la
ng={false} />
+  <NavBar width={MOBILE_FRAME_W} gap={1} la
ng={false} />
   <Pre>{ruleD(MOBILE_FRAME_W)}</Pre>
 </div>
 
 

diff --git a/ooknet-design/src/components/Tree/Tree.astro b/ooknet-design/src/comp
onents/Tree/Tree.astro
new file mode 100644
index 0000000..1dc4c54
--- /dev/null
+++ b/ooknet-design/src/components/Tree/Tree.astro
@@ -0,0 +1,11 @@
+---
+import Pre from "../Pre/Pre.astro";
+import { layoutTree, type Props } from "./Tree";
+import "./Tree.scss";
+
+const { nodes } = Astro.props as Props;
+const rows = layoutTree(nodes);
+---
+{rows.map((r) => (
+  <Pre class="tree-row">{r.prefix}{r.href ? <a class="tree-link" href={r.href}>{r
.label}</a> : r.label}</Pre>
+))}

diff --git a/ooknet-design/src/components/Tr
ee/Tree.astro b/ooknet-design/src/components
/Tree/Tree.astro
new file mode 100644
index 0000000..1dc4c54
--- /dev/null
+++ b/ooknet-design/src/components/Tree/Tree
.astro
@@ -0,0 +1,11 @@
+---
+import Pre from "../Pre/Pre.astro";
+import { layoutTree, type Props } from "./T
ree";
+import "./Tree.scss";
+
+const { nodes } = Astro.props as Props;
+const rows = layoutTree(nodes);
+---
+{rows.map((r) => (
+  <Pre class="tree-row">{r.prefix}{r.href ?
 <a class="tree-link" href={r.href}>{r.label
}</a> : r.label}</Pre>
+))}
 

diff --git a/ooknet-design/src/components/NavButton/NavButton.scss b/ooknet-design
/src/components/Tree/Tree.scss
similarity index 79%
rename from ooknet-design/src/components/NavButton/NavButton.scss
rename to ooknet-design/src/components/Tree/Tree.scss
index 21862f2..38aeccf 100644
--- a/ooknet-design/src/components/NavButton/NavButton.scss
+++ b/ooknet-design/src/components/Tree/Tree.scss
@@ -1,4 +1,4 @@
-.nav-button {
+.tree-link {
   color: var(--ink);
   text-decoration: none;
 
@@ -7,10 +7,6 @@
     text-underline-offset: 3px;
   }
 
-  &.active {
-    font-weight: 600;
-  }
-
   &:focus-visible {
     outline: 1px dashed var(--ink);
     outline-offset: 1px;

diff --git a/ooknet-design/src/components/Na
vButton/NavButton.scss b/ooknet-design/src/c
omponents/Tree/Tree.scss
similarity index 79%
rename from ooknet-design/src/components/Nav
Button/NavButton.scss
rename to ooknet-design/src/components/Tree/
Tree.scss
index 21862f2..38aeccf 100644
--- a/ooknet-design/src/components/NavButton
/NavButton.scss
+++ b/ooknet-design/src/components/Tree/Tree
.scss
@@ -1,4 +1,4 @@
-.nav-button {
+.tree-link {
   color: var(--ink);
   text-decoration: none;
 
@@ -7,10 +7,6 @@
     text-underline-offset: 3px;
   }
 
-  &.active {
-    font-weight: 600;
-  }
-
   &:focus-visible {
     outline: 1px dashed var(--ink);
     outline-offset: 1px;
 

diff --git a/ooknet-design/src/components/Tree/Tree.test.ts b/ooknet-design/src/co
mponents/Tree/Tree.test.ts
new file mode 100644
index 0000000..bd485de
--- /dev/null
+++ b/ooknet-design/src/components/Tree/Tree.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { layoutTree, type TreeNode } from "./Tree";
+
+const TREE: TreeNode[] = [
+  {
+    label: "/INFO",
+    children: [
+      { label: "GRAPH", children: [{ label: "ENTROPY" }, { label: "SPECTRAL" }] }
,
+      { label: "AI" },
+    ],
+  },
+];
+
+describe("layoutTree", () => {
+  it("renders depth 0 bare and branches below it", () => {
+    const rows = layoutTree(TREE);
+    expect(rows.map((r) => r.prefix + r.label)).toEqual([
+      "/INFO",
+      "├── GRAPH",
+      "│   ├── ENTROPY",
+      "│   └── SPECTRAL",
+      "└── AI",
+    ]);
+  });
+
+  it("uses └── for the last sibling and pads descendants past it", () => {
+    const rows = layoutTree([
+      { label: "R", children: [{ label: "LAST", children: [{ label: "LEAF" }] }] 
},
+    ]);
+    expect(rows.map((r) => r.prefix + r.label)).toEqual(["R", "└── LAST", "    └─
─ LEAF"]);
+  });
+
+  it("carries href through to the row", () => {
+    const rows = layoutTree([{ label: "A", href: "/a/" }]);
+    expect(rows[0].href).toBe("/a/");
+  });
+
+  it("handles empty input", () => {
+    expect(layoutTree([])).toEqual([]);
+  });
+});

diff --git a/ooknet-design/src/components/Tr
ee/Tree.test.ts b/ooknet-design/src/componen
ts/Tree/Tree.test.ts
new file mode 100644
index 0000000..bd485de
--- /dev/null
+++ b/ooknet-design/src/components/Tree/Tree
.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vites
t";
+import { layoutTree, type TreeNode } from "
./Tree";
+
+const TREE: TreeNode[] = [
+  {
+    label: "/INFO",
+    children: [
+      { label: "GRAPH", children: [{ label:
 "ENTROPY" }, { label: "SPECTRAL" }] },
+      { label: "AI" },
+    ],
+  },
+];
+
+describe("layoutTree", () => {
+  it("renders depth 0 bare and branches bel
ow it", () => {
+    const rows = layoutTree(TREE);
+    expect(rows.map((r) => r.prefix + r.lab
el)).toEqual([
+      "/INFO",
+      "├── GRAPH",
+      "│   ├── ENTROPY",
+      "│   └── SPECTRAL",
+      "└── AI",
+    ]);
+  });
+
+  it("uses └── for the last sibling and pad
s descendants past it", () => {
+    const rows = layoutTree([
+      { label: "R", children: [{ label: "LA
ST", children: [{ label: "LEAF" }] }] },
+    ]);
+    expect(rows.map((r) => r.prefix + r.lab
el)).toEqual(["R", "└── LAST", "    └── LEAF
"]);
+  });
+
+  it("carries href through to the row", () 
=> {
+    const rows = layoutTree([{ label: "A", 
href: "/a/" }]);
+    expect(rows[0].href).toBe("/a/");
+  });
+
+  it("handles empty input", () => {
+    expect(layoutTree([])).toEqual([]);
+  });
+});
 

diff --git a/ooknet-design/src/components/Tree/Tree.ts b/ooknet-design/src/compone
nts/Tree/Tree.ts
new file mode 100644
index 0000000..07be091
--- /dev/null
+++ b/ooknet-design/src/components/Tree/Tree.ts
@@ -0,0 +1,35 @@
+export interface TreeNode {
+  label: string;
+  href?: string;
+  children?: TreeNode[];
+}
+
+export interface TreeRow {
+  prefix: string;
+  label: string;
+  href?: string;
+}
+
+export interface Props {
+  nodes: TreeNode[];
+}
+
+// Depth 0 renders bare (a root line); deeper levels get branch glyphs.
+export function layoutTree(nodes: TreeNode[]): TreeRow[] {
+  const out: TreeRow[] = [];
+  const walk = (ns: TreeNode[], prefix: string, depth: number) => {
+    ns.forEach((n, i) => {
+      const last = i === ns.length - 1;
+      out.push({
+        prefix: depth === 0 ? "" : prefix + (last ? "└── " : "├── "),
+        label: n.label,
+        href: n.href,
+      });
+      if (n.children?.length) {
+        walk(n.children, depth === 0 ? "" : prefix + (last ? "    " : "│   "), de
pth + 1);
+      }
+    });
+  };
+  walk(nodes, "", 0);
+  return out;
+}

diff --git a/ooknet-design/src/components/Tr
ee/Tree.ts b/ooknet-design/src/components/Tr
ee/Tree.ts
new file mode 100644
index 0000000..07be091
--- /dev/null
+++ b/ooknet-design/src/components/Tree/Tree
.ts
@@ -0,0 +1,35 @@
+export interface TreeNode {
+  label: string;
+  href?: string;
+  children?: TreeNode[];
+}
+
+export interface TreeRow {
+  prefix: string;
+  label: string;
+  href?: string;
+}
+
+export interface Props {
+  nodes: TreeNode[];
+}
+
+// Depth 0 renders bare (a root line); deep
er levels get branch glyphs.
+export function layoutTree(nodes: TreeNode[
]): TreeRow[] {
+  const out: TreeRow[] = [];
+  const walk = (ns: TreeNode[], prefix: str
ing, depth: number) => {
+    ns.forEach((n, i) => {
+      const last = i === ns.length - 1;
+      out.push({
+        prefix: depth === 0 ? "" : prefix +
 (last ? "└── " : "├── "),
+        label: n.label,
+        href: n.href,
+      });
+      if (n.children?.length) {
+        walk(n.children, depth === 0 ? "" :
 prefix + (last ? "    " : "│   "), depth + 
1);
+      }
+    });
+  };
+  walk(nodes, "", 0);
+  return out;
+}
 

diff --git a/ooknet-design/src/lib/ascii.ts b/ooknet-design/src/lib/ascii.ts
index afdd52d..5bc1c47 100644
--- a/ooknet-design/src/lib/ascii.ts
+++ b/ooknet-design/src/lib/ascii.ts
@@ -13,11 +13,12 @@ export const len = (s: unknown): number => [...String(s ?? "")
].length;
 
 // Codepoints known to render exactly one cell wide in the frame font
 // (fontsource Cascadia Mono, latin subset): printable ASCII + tab,
-// Latin-1 supplement, box drawing, and the measured extras - - - ...  ↑ ↓.
+// Latin-1 supplement, box drawing + block elements, and the measured
+// extras - - - ...  ↑ ↓.
 // Anything else - emoji, CJK, glyphs the subset lacks (?, ?, ?) - bends
 // a frame border. Measure a new glyph against "0" in the browser before
 // adding it here.
-const ON_GRID = /^[\t\x20-\x7E\u00A0-\u00FF\u2013\u2014\u2022\u2026\u2122\u2191\u
2193\u2500-\u257F]$/u;
+const ON_GRID = /^[\t\x20-\x7E\u00A0-\u00FF\u2013\u2014\u2022\u2026\u2122\u2191\u
2193\u2500-\u259F]$/u;
 
 /** Throws unless every line of `text` fits `maxW` cells and uses only
  *  on-grid glyphs. Returns `text` so call sites can wrap in place. */

diff --git a/ooknet-design/src/lib/ascii.ts 
b/ooknet-design/src/lib/ascii.ts
index afdd52d..5bc1c47 100644
--- a/ooknet-design/src/lib/ascii.ts
+++ b/ooknet-design/src/lib/ascii.ts
@@ -13,11 +13,12 @@ export const len = (s: u
nknown): number => [...String(s ?? "")].leng
th;
 
 // Codepoints known to render exactly one c
ell wide in the frame font
 // (fontsource Cascadia Mono, latin subset)
: printable ASCII + tab,
-// Latin-1 supplement, box drawing, and the
 measured extras - - - ...  ↑ ↓.
+// Latin-1 supplement, box drawing + block 
elements, and the measured
+// extras - - - ...  ↑ ↓.
 // Anything else - emoji, CJK, glyphs the s
ubset lacks (?, ?, ?) - bends
 // a frame border. Measure a new glyph agai
nst "0" in the browser before
 // adding it here.
-const ON_GRID = /^[\t\x20-\x7E\u00A0-\u00FF
\u2013\u2014\u2022\u2026\u2122\u2191\u2193\u
2500-\u257F]$/u;
+const ON_GRID = /^[\t\x20-\x7E\u00A0-\u00FF
\u2013\u2014\u2022\u2026\u2122\u2191\u2193\u
2500-\u259F]$/u;
 
 /** Throws unless every line of `text` fits
 `maxW` cells and uses only
  *  on-grid glyphs. Returns `text` so call 
sites can wrap in place. */
 

diff --git a/ooknet-design/src/pages/components.astro b/ooknet-design/src/pages/co
mponents.astro
index 899ca73..106ca2c 100644
--- a/ooknet-design/src/pages/components.astro
+++ b/ooknet-design/src/pages/components.astro
@@ -4,36 +4,59 @@ 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";
+import FormFrame from "../components/FormFrame/FormFrame.astro";
+import type { Field } from "../components/FormFrame/FormFrame";
+import Tree from "../components/Tree/Tree.astro";
+import type { TreeNode } from "../components/Tree/Tree";
+import Progress from "../components/Progress/Progress.astro";
+import Spinner from "../components/Spinner/Spinner.astro";
+import Status from "../components/Status/Status.astro";
+import { rule, ruleD, row2, pad } from "../lib/ascii";
 
 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 REPORT_FIELDS: Field[] = [
+  { kind: "text", label: "CALLSIGN", name: "callsign", cells: 26 },
+  { kind: "text", label: "FREQUENCY", name: "frequency", cells: 26 },
+  { kind: "select", label: "CHANNEL", name: "channel", options: ["ALPHA", "BRAVO"
, "OOKNET-9"], cells: 20, value: "ALPHA" },
+  { kind: "gap" },
+  { kind: "radios", label: "PRIORITY", name: "priority", options: ["ROUTINE", "UR
GENT", "FLASH"], value: "ROUTINE" },
+  { kind: "gap" },
+  { kind: "checkbox", label: "ENCRYPT TRANSMISSION", name: "encrypt", checked: tr
ue },
+  { kind: "checkbox", label: "NOTIFY ON REPLY", name: "notify" },
+  { kind: "gap" },
+  { kind: "textarea", label: "MESSAGE", name: "message", rows: 4 },
+  { kind: "gap" },
+  { kind: "buttons", buttons: [{ label: "TRANSMIT", variant: "primary" }, { label
: "CLEAR", type: "reset" }] },
+];
 
-const FIELD_CELLS = 26;
-const fieldUsed = LABEL_W + FIELD_CELLS;
-const chanUsed = LABEL_W + selectWidth(20);
-const submitUsed = buttonWidth("TRANSMIT") + 2 + buttonWidth("CLEAR");
+const FILED_TREE: TreeNode[] = [
+  {
+    label: "/INFO",
+    children: [
+      {
+        label: "GRAPH",
+        children: [{ label: "ENTROPY", children: [{ label: "BOUND - N217", href: 
"/notes/n217-entropy-bounds/" }] }],
+      },
+      {
+        label: "WINDOWS",
+        children: [{ label: "PERF", children: [{ label: "TSS - N218", href: "/not
es/collect-data-to-analyze-and-troubleshoot-performance-scenarios/" }] }],
+      },
+      { label: "AI", children: [{ label: "TROUBLESHOOTING - N219", href: "/notes/
n219-using-ai-to-troubleshoot/" }] },
+    ],
+  },
+  {
+    label: "/META",
+    children: [{ label: "STYLE", children: [{ label: "SHEET - N0", href: "/notes/
n0-style-sheet/" }] }],
+  },
+];
 ---
 <ArticleLayout title="OOKNET KB - Components">
   <TopHeader />
@@ -67,24 +90,32 @@ const submitUsed = buttonWidth("TRANSMIT") + 2 + buttonWidth("
CLEAR");
   <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>
+  <Frame build={section("FORM FRAME", "FormFrame.astro")} />
   <Pre>{" "}</Pre>
+  <FormFrame title="FIELD REPORT" fields={REPORT_FIELDS} />
+  <Pre>{" "}</Pre>
+  <Pre><Status variant="ok" message="TRANSMISSION QUEUED" /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("TREE", "Tree.astro")} />
+  <Pre>{" "}</Pre>
+  <Tree nodes={FILED_TREE} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("PROGRESS", "Progress.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("REINDEX", LABEL_W)}<Progress value={57} /></Pre>
+  <Pre>{pad("UPLINK", LABEL_W)}<Progress value={100} /></Pre>
+  <Pre>{pad("DECAY", LABEL_W)}<Progress value={8} /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("STATUS", "Status.astro / Spinner.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre><Status variant="ok" message="TRANSMISSION QUEUED" /></Pre>
+  <Pre><Status variant="error" message="FREQUENCY OUT OF BAND" /></Pre>
+  <Pre><Status variant="pending" message="AWAITING PEER CHECK" /></Pre>
+  <Pre><Spinner />{" SYNCING INDEX"}</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
index 899ca73..106ca2c 100644
--- a/ooknet-design/src/pages/components.ast
ro
+++ b/ooknet-design/src/pages/components.ast
ro
@@ -4,36 +4,59 @@ import TopHeader from "../
components/TopHeader/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";
+import FormFrame from "../components/FormFr
ame/FormFrame.astro";
+import type { Field } from "../components/F
ormFrame/FormFrame";
+import Tree from "../components/Tree/Tree.a
stro";
+import type { TreeNode } from "../component
s/Tree/Tree";
+import Progress from "../components/Progres
s/Progress.astro";
+import Spinner from "../components/Spinner/
Spinner.astro";
+import Status from "../components/Status/St
atus.astro";
+import { rule, ruleD, row2, pad } from "../
lib/ascii";
 
 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 REPORT_FIELDS: Field[] = [
+  { kind: "text", label: "CALLSIGN", name: 
"callsign", cells: 26 },
+  { kind: "text", label: "FREQUENCY", name:
 "frequency", cells: 26 },
+  { kind: "select", label: "CHANNEL", name:
 "channel", options: ["ALPHA", "BRAVO", "OOK
NET-9"], cells: 20, value: "ALPHA" },
+  { kind: "gap" },
+  { kind: "radios", label: "PRIORITY", name
: "priority", options: ["ROUTINE", "URGENT",
 "FLASH"], value: "ROUTINE" },
+  { kind: "gap" },
+  { kind: "checkbox", label: "ENCRYPT TRANS
MISSION", name: "encrypt", checked: true },
+  { kind: "checkbox", label: "NOTIFY ON REP
LY", name: "notify" },
+  { kind: "gap" },
+  { kind: "textarea", label: "MESSAGE", nam
e: "message", rows: 4 },
+  { kind: "gap" },
+  { kind: "buttons", buttons: [{ label: "TR
ANSMIT", variant: "primary" }, { label: "CLE
AR", type: "reset" }] },
+];
 
-const FIELD_CELLS = 26;
-const fieldUsed = LABEL_W + FIELD_CELLS;
-const chanUsed = LABEL_W + selectWidth(20);
-const submitUsed = buttonWidth("TRANSMIT") 
+ 2 + buttonWidth("CLEAR");
+const FILED_TREE: TreeNode[] = [
+  {
+    label: "/INFO",
+    children: [
+      {
+        label: "GRAPH",
+        children: [{ label: "ENTROPY", chil
dren: [{ label: "BOUND - N217", href: "/note
s/n217-entropy-bounds/" }] }],
+      },
+      {
+        label: "WINDOWS",
+        children: [{ label: "PERF", childre
n: [{ label: "TSS - N218", href: "/notes/col
lect-data-to-analyze-and-troubleshoot-perfor
mance-scenarios/" }] }],
+      },
+      { label: "AI", children: [{ label: "T
ROUBLESHOOTING - N219", href: "/notes/n219-u
sing-ai-to-troubleshoot/" }] },
+    ],
+  },
+  {
+    label: "/META",
+    children: [{ label: "STYLE", children: 
[{ label: "SHEET - N0", href: "/notes/n0-sty
le-sheet/" }] }],
+  },
+];
 ---
 <ArticleLayout title="OOKNET KB - Component
s">
   <TopHeader />
@@ -67,24 +90,32 @@ const submitUsed = butto
nWidth("TRANSMIT") + 2 + buttonWidth("CLEAR"
);
   <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>
+  <Frame build={section("FORM FRAME", "Form
Frame.astro")} />
   <Pre>{" "}</Pre>
+  <FormFrame title="FIELD REPORT" fields={R
EPORT_FIELDS} />
+  <Pre>{" "}</Pre>
+  <Pre><Status variant="ok" message="TRANSM
ISSION QUEUED" /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("TREE", "Tree.astro
")} />
+  <Pre>{" "}</Pre>
+  <Tree nodes={FILED_TREE} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("PROGRESS", "Progre
ss.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre>{pad("REINDEX", LABEL_W)}<Progress v
alue={57} /></Pre>
+  <Pre>{pad("UPLINK", LABEL_W)}<Progress va
lue={100} /></Pre>
+  <Pre>{pad("DECAY", LABEL_W)}<Progress val
ue={8} /></Pre>
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("STATUS", "Status.a
stro / Spinner.astro")} />
+  <Pre>{" "}</Pre>
+  <Pre><Status variant="ok" message="TRANSM
ISSION QUEUED" /></Pre>
+  <Pre><Status variant="error" message="FRE
QUENCY OUT OF BAND" /></Pre>
+  <Pre><Status variant="pending" message="A
WAITING PEER CHECK" /></Pre>
+  <Pre><Spinner />{" SYNCING INDEX"}</Pre>
+  <Pre>{" "}</Pre>
+
   <Frame build={(w) => rule(w)} />
 </ArticleLayout>
 
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET