OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
HASH      61fed2d9c8fa
DATE      2026-07-17
SUBJECT   web: panel, bar chart, and big number components
FILES     12 CHANGED
HASH      61fed2d9c8fa
DATE      2026-07-17
SUBJECT   web: panel, bar chart, and big number
          components
FILES     12 CHANGED
 

diff --git a/ooknet-design/src/components/BarChart/BarChart.astro b/ooknet-design/
src/components/BarChart/BarChart.astro
new file mode 100644
index 0000000..5cea405
--- /dev/null
+++ b/ooknet-design/src/components/BarChart/BarChart.astro
@@ -0,0 +1,7 @@
+---
+import Frame from "../Frame/Frame.astro";
+import { buildBarChart, type Props } from "./BarChart";
+
+const { items, labelW, max } = Astro.props as Props;
+---
+<Frame build={(w) => buildBarChart(items, w, { labelW, max })} />

diff --git a/ooknet-design/src/components/Ba
rChart/BarChart.astro b/ooknet-design/src/co
mponents/BarChart/BarChart.astro
new file mode 100644
index 0000000..5cea405
--- /dev/null
+++ b/ooknet-design/src/components/BarChart/
BarChart.astro
@@ -0,0 +1,7 @@
+---
+import Frame from "../Frame/Frame.astro";
+import { buildBarChart, type Props } from "
./BarChart";
+
+const { items, labelW, max } = Astro.props 
as Props;
+---
+<Frame build={(w) => buildBarChart(items, w
, { labelW, max })} />
 

diff --git a/ooknet-design/src/components/BarChart/BarChart.test.ts b/ooknet-desig
n/src/components/BarChart/BarChart.test.ts
new file mode 100644
index 0000000..996c087
--- /dev/null
+++ b/ooknet-design/src/components/BarChart/BarChart.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+import { buildBarChart } from "./BarChart";
+import { len } from "../../lib/ascii";
+
+const items = [
+  { label: "NIXOS", value: 12 },
+  { label: "WINDOWS-TROUBLESHOOTING", value: 3 },
+  { label: "EMPTY", value: 0 },
+];
+
+describe("buildBarChart", () => {
+  it("renders every row at the exact width", () => {
+    for (const w of [86, 48]) {
+      for (const line of buildBarChart(items, w).split("\n")) {
+        expect(len(line)).toBe(w);
+      }
+    }
+  });
+
+  it("fills the largest value to the full bar and zero to none", () => {
+    const [top, , zero] = buildBarChart(items, 48).split("\n");
+    expect(top).not.toContain("░");
+    expect(zero).not.toContain("█");
+  });
+
+  it("truncates long labels with an ellipsis", () => {
+    const lines = buildBarChart(items, 48).split("\n");
+    expect(lines[1]).toContain("WINDOWS-TRO...");
+  });
+
+  it("right-aligns values", () => {
+    const lines = buildBarChart(items, 48).split("\n");
+    expect(lines[0].endsWith("12")).toBe(true);
+    expect(lines[2].endsWith(" 0")).toBe(true);
+  });
+
+  it("scales against an explicit max", () => {
+    const line = buildBarChart([{ label: "HALF", value: 5 }], 48, { max: 10 });
+    const filled = [...line].filter((c) => c === "█").length;
+    const track = [...line].filter((c) => c === "░").length;
+    expect(Math.abs(filled - track)).toBeLessThanOrEqual(1);
+  });
+
+  it("returns empty for no items", () => {
+    expect(buildBarChart([], 48)).toBe("");
+  });
+});

diff --git a/ooknet-design/src/components/Ba
rChart/BarChart.test.ts b/ooknet-design/src/
components/BarChart/BarChart.test.ts
new file mode 100644
index 0000000..996c087
--- /dev/null
+++ b/ooknet-design/src/components/BarChart/
BarChart.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vites
t";
+import { buildBarChart } from "./BarChart";
+import { len } from "../../lib/ascii";
+
+const items = [
+  { label: "NIXOS", value: 12 },
+  { label: "WINDOWS-TROUBLESHOOTING", value
: 3 },
+  { label: "EMPTY", value: 0 },
+];
+
+describe("buildBarChart", () => {
+  it("renders every row at the exact width"
, () => {
+    for (const w of [86, 48]) {
+      for (const line of buildBarChart(item
s, w).split("\n")) {
+        expect(len(line)).toBe(w);
+      }
+    }
+  });
+
+  it("fills the largest value to the full b
ar and zero to none", () => {
+    const [top, , zero] = buildBarChart(ite
ms, 48).split("\n");
+    expect(top).not.toContain("░");
+    expect(zero).not.toContain("█");
+  });
+
+  it("truncates long labels with an ellipsi
s", () => {
+    const lines = buildBarChart(items, 48).
split("\n");
+    expect(lines[1]).toContain("WINDOWS-TRO
...");
+  });
+
+  it("right-aligns values", () => {
+    const lines = buildBarChart(items, 48).
split("\n");
+    expect(lines[0].endsWith("12")).toBe(tr
ue);
+    expect(lines[2].endsWith(" 0")).toBe(tr
ue);
+  });
+
+  it("scales against an explicit max", () =
> {
+    const line = buildBarChart([{ label: "H
ALF", value: 5 }], 48, { max: 10 });
+    const filled = [...line].filter((c) => 
c === "█").length;
+    const track = [...line].filter((c) => c
 === "░").length;
+    expect(Math.abs(filled - track)).toBeLe
ssThanOrEqual(1);
+  });
+
+  it("returns empty for no items", () => {
+    expect(buildBarChart([], 48)).toBe("");
+  });
+});
 

diff --git a/ooknet-design/src/components/BarChart/BarChart.ts b/ooknet-design/src
/components/BarChart/BarChart.ts
new file mode 100644
index 0000000..d881daf
--- /dev/null
+++ b/ooknet-design/src/components/BarChart/BarChart.ts
@@ -0,0 +1,42 @@
+// Horizontal bar chart:
+// ENTROPY     ████████████████░░░░░░░░  12
+import { checkGrid, len, pad } from "../../lib/ascii";
+
+export interface BarItem {
+  label: string;
+  value: number;
+}
+
+export interface Props {
+  items: BarItem[];
+  /** Label column cells; longer labels truncate with .... */
+  labelW?: number;
+  /** Scale ceiling; defaults to the largest value. */
+  max?: number;
+}
+
+export function buildBarChart(
+  items: BarItem[],
+  width: number,
+  { labelW = 12, max }: Omit<Props, "items"> = {},
+): string {
+  if (!items.length) return "";
+  const top = max ?? Math.max(...items.map((i) => i.value));
+  const valueW = Math.max(...items.map((i) => len(String(i.value))));
+  const barW = width - labelW - valueW - 2;
+  if (barW < 4) {
+    throw new Error(`[ascii] bar chart needs ${labelW + valueW + 6} cells, got ${
width}`);
+  }
+  return items
+    .map((it) => {
+      const label =
+        len(it.label) > labelW
+          ? [...it.label].slice(0, labelW - 1).join("") + "..."
+          : it.label;
+      const filled = top <= 0 ? 0 : Math.round((Math.max(0, it.value) / top) * ba
rW);
+      const bar = "█".repeat(filled) + "░".repeat(barW - filled);
+      const line = pad(label, labelW) + bar + "  " + pad(String(it.value), valueW
, "start");
+      return checkGrid(line, width, "bar chart row");
+    })
+    .join("\n");
+}

diff --git a/ooknet-design/src/components/Ba
rChart/BarChart.ts b/ooknet-design/src/compo
nents/BarChart/BarChart.ts
new file mode 100644
index 0000000..d881daf
--- /dev/null
+++ b/ooknet-design/src/components/BarChart/
BarChart.ts
@@ -0,0 +1,42 @@
+// Horizontal bar chart:
+// ENTROPY     ████████████████░░░░░░░░  12
+import { checkGrid, len, pad } from "../../
lib/ascii";
+
+export interface BarItem {
+  label: string;
+  value: number;
+}
+
+export interface Props {
+  items: BarItem[];
+  /** Label column cells; longer labels tru
ncate with .... */
+  labelW?: number;
+  /** Scale ceiling; defaults to the larges
t value. */
+  max?: number;
+}
+
+export function buildBarChart(
+  items: BarItem[],
+  width: number,
+  { labelW = 12, max }: Omit<Props, "items"
> = {},
+): string {
+  if (!items.length) return "";
+  const top = max ?? Math.max(...items.map(
(i) => i.value));
+  const valueW = Math.max(...items.map((i) 
=> len(String(i.value))));
+  const barW = width - labelW - valueW - 2;
+  if (barW < 4) {
+    throw new Error(`[ascii] bar chart need
s ${labelW + valueW + 6} cells, got ${width}
`);
+  }
+  return items
+    .map((it) => {
+      const label =
+        len(it.label) > labelW
+          ? [...it.label].slice(0, labelW -
 1).join("") + "..."
+          : it.label;
+      const filled = top <= 0 ? 0 : Math.ro
und((Math.max(0, it.value) / top) * barW);
+      const bar = "█".repeat(filled) + "░".
repeat(barW - filled);
+      const line = pad(label, labelW) + bar
 + "  " + pad(String(it.value), valueW, "sta
rt");
+      return checkGrid(line, width, "bar ch
art row");
+    })
+    .join("\n");
+}
 

diff --git a/ooknet-design/src/components/BigNumber/BigNumber.astro b/ooknet-desig
n/src/components/BigNumber/BigNumber.astro
new file mode 100644
index 0000000..c153673
--- /dev/null
+++ b/ooknet-design/src/components/BigNumber/BigNumber.astro
@@ -0,0 +1,9 @@
+---
+import Frame from "../Frame/Frame.astro";
+import Pre from "../Pre/Pre.astro";
+import { buildBigNumber, type Props } from "./BigNumber";
+
+const { value, label } = Astro.props as Props;
+---
+<Frame build={(w) => buildBigNumber(value, w)} />
+{label && <Pre>{label}</Pre>}

diff --git a/ooknet-design/src/components/Bi
gNumber/BigNumber.astro b/ooknet-design/src/
components/BigNumber/BigNumber.astro
new file mode 100644
index 0000000..c153673
--- /dev/null
+++ b/ooknet-design/src/components/BigNumber
/BigNumber.astro
@@ -0,0 +1,9 @@
+---
+import Frame from "../Frame/Frame.astro";
+import Pre from "../Pre/Pre.astro";
+import { buildBigNumber, type Props } from 
"./BigNumber";
+
+const { value, label } = Astro.props as Pro
ps;
+---
+<Frame build={(w) => buildBigNumber(value, 
w)} />
+{label && <Pre>{label}</Pre>}
 

diff --git a/ooknet-design/src/components/BigNumber/BigNumber.test.ts b/ooknet-des
ign/src/components/BigNumber/BigNumber.test.ts
new file mode 100644
index 0000000..ee160c7
--- /dev/null
+++ b/ooknet-design/src/components/BigNumber/BigNumber.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { buildBigNumber, BIG_ROWS } from "./BigNumber";
+import { len } from "../../lib/ascii";
+
+describe("buildBigNumber", () => {
+  it("renders five rows of block glyphs", () => {
+    const rows = buildBigNumber(42, 86).split("\n");
+    expect(rows).toHaveLength(BIG_ROWS);
+    expect(rows.join("")).toMatch(/█/);
+  });
+
+  it("keeps digits four cells apart", () => {
+    // "10" ? 3 + 1 gap + 3 = 7 cells on the widest row
+    const rows = buildBigNumber(10, 86).split("\n");
+    expect(Math.max(...rows.map(len))).toBe(7);
+  });
+
+  it("supports separators", () => {
+    expect(() => buildBigNumber("1,024.5", 86)).not.toThrow();
+  });
+
+  it("throws on characters without glyphs", () => {
+    expect(() => buildBigNumber("42%", 86)).toThrow(/no glyph/);
+  });
+
+  it("fails the grid when the figure overflows the frame", () => {
+    expect(() => buildBigNumber("888888888888888", 48)).toThrow(/cells/);
+  });
+});

diff --git a/ooknet-design/src/components/Bi
gNumber/BigNumber.test.ts b/ooknet-design/sr
c/components/BigNumber/BigNumber.test.ts
new file mode 100644
index 0000000..ee160c7
--- /dev/null
+++ b/ooknet-design/src/components/BigNumber
/BigNumber.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vites
t";
+import { buildBigNumber, BIG_ROWS } from ".
/BigNumber";
+import { len } from "../../lib/ascii";
+
+describe("buildBigNumber", () => {
+  it("renders five rows of block glyphs", (
) => {
+    const rows = buildBigNumber(42, 86).spl
it("\n");
+    expect(rows).toHaveLength(BIG_ROWS);
+    expect(rows.join("")).toMatch(/█/);
+  });
+
+  it("keeps digits four cells apart", () =>
 {
+    // "10" ? 3 + 1 gap + 3 = 7 cells on th
e widest row
+    const rows = buildBigNumber(10, 86).spl
it("\n");
+    expect(Math.max(...rows.map(len))).toBe
(7);
+  });
+
+  it("supports separators", () => {
+    expect(() => buildBigNumber("1,024.5", 
86)).not.toThrow();
+  });
+
+  it("throws on characters without glyphs",
 () => {
+    expect(() => buildBigNumber("42%", 86))
.toThrow(/no glyph/);
+  });
+
+  it("fails the grid when the figure overfl
ows the frame", () => {
+    expect(() => buildBigNumber("8888888888
88888", 48)).toThrow(/cells/);
+  });
+});
 

diff --git a/ooknet-design/src/components/BigNumber/BigNumber.ts b/ooknet-design/s
rc/components/BigNumber/BigNumber.ts
new file mode 100644
index 0000000..f3579e5
--- /dev/null
+++ b/ooknet-design/src/components/BigNumber/BigNumber.ts
@@ -0,0 +1,41 @@
+// Headline figures as 3?5 block digits - every cell a real glyph.
+import { checkGrid, len } from "../../lib/ascii";
+
+export interface Props {
+  value: string | number;
+  /** Caption under the digits. */
+  label?: string;
+}
+
+// prettier-ignore
+const DIGITS: Record<string, string[]> = {
+  "0": ["███", "█ █", "█ █", "█ █", "███"],
+  "1": ["  █", "  █", "  █", "  █", "  █"],
+  "2": ["███", "  █", "███", "█  ", "███"],
+  "3": ["███", "  █", "███", "  █", "███"],
+  "4": ["█ █", "█ █", "███", "  █", "  █"],
+  "5": ["███", "█  ", "███", "  █", "███"],
+  "6": ["███", "█  ", "███", "█ █", "███"],
+  "7": ["███", "  █", "  █", "  █", "  █"],
+  "8": ["███", "█ █", "███", "█ █", "███"],
+  "9": ["███", "█ █", "███", "  █", "███"],
+  ".": [" ", " ", " ", " ", "█"],
+  ",": [" ", " ", " ", "█", "█"],
+  "-": ["   ", "   ", "███", "   ", "   "],
+  " ": [" ", " ", " ", " ", " "],
+};
+
+export const BIG_ROWS = 5;
+
+export function buildBigNumber(value: string | number, width: number): string {
+  const chars = [...String(value)];
+  const rows = Array.from({ length: BIG_ROWS }, () => "");
+  for (const [i, c] of chars.entries()) {
+    const glyph = DIGITS[c];
+    if (!glyph) throw new Error(`[ascii] big number has no glyph for "${c}"`);
+    for (let r = 0; r < BIG_ROWS; r++) {
+      rows[r] += (i > 0 ? " " : "") + glyph[r];
+    }
+  }
+  return rows.map((r) => checkGrid(r.trimEnd(), width, "big number")).join("\n");
+}

diff --git a/ooknet-design/src/components/Bi
gNumber/BigNumber.ts b/ooknet-design/src/com
ponents/BigNumber/BigNumber.ts
new file mode 100644
index 0000000..f3579e5
--- /dev/null
+++ b/ooknet-design/src/components/BigNumber
/BigNumber.ts
@@ -0,0 +1,41 @@
+// Headline figures as 3?5 block digits - e
very cell a real glyph.
+import { checkGrid, len } from "../../lib/a
scii";
+
+export interface Props {
+  value: string | number;
+  /** Caption under the digits. */
+  label?: string;
+}
+
+// prettier-ignore
+const DIGITS: Record<string, string[]> = {
+  "0": ["███", "█ █", "█ █", "█ █", "███"],
+  "1": ["  █", "  █", "  █", "  █", "  █"],
+  "2": ["███", "  █", "███", "█  ", "███"],
+  "3": ["███", "  █", "███", "  █", "███"],
+  "4": ["█ █", "█ █", "███", "  █", "  █"],
+  "5": ["███", "█  ", "███", "  █", "███"],
+  "6": ["███", "█  ", "███", "█ █", "███"],
+  "7": ["███", "  █", "  █", "  █", "  █"],
+  "8": ["███", "█ █", "███", "█ █", "███"],
+  "9": ["███", "█ █", "███", "  █", "███"],
+  ".": [" ", " ", " ", " ", "█"],
+  ",": [" ", " ", " ", "█", "█"],
+  "-": ["   ", "   ", "███", "   ", "   "],
+  " ": [" ", " ", " ", " ", " "],
+};
+
+export const BIG_ROWS = 5;
+
+export function buildBigNumber(value: strin
g | number, width: number): string {
+  const chars = [...String(value)];
+  const rows = Array.from({ length: BIG_ROW
S }, () => "");
+  for (const [i, c] of chars.entries()) {
+    const glyph = DIGITS[c];
+    if (!glyph) throw new Error(`[ascii] bi
g number has no glyph for "${c}"`);
+    for (let r = 0; r < BIG_ROWS; r++) {
+      rows[r] += (i > 0 ? " " : "") + glyph
[r];
+    }
+  }
+  return rows.map((r) => checkGrid(r.trimEn
d(), width, "big number")).join("\n");
+}
 

diff --git a/ooknet-design/src/components/Panel/Panel.astro b/ooknet-design/src/co
mponents/Panel/Panel.astro
new file mode 100644
index 0000000..1f8b504
--- /dev/null
+++ b/ooknet-design/src/components/Panel/Panel.astro
@@ -0,0 +1,15 @@
+---
+import Pre from "../Pre/Pre.astro";
+import { FRAME_W, MOBILE_FRAME_W } from "../../lib/config";
+import { buildPanelTop, buildPanelBottom, type Props } from "./Panel";
+import "./Panel.scss";
+
+const { title } = Astro.props as Props;
+---
+<section class="panel">
+  <Pre class="frame-wide">{buildPanelTop(FRAME_W, title)}</Pre>
+  <Pre class="frame-narrow">{buildPanelTop(MOBILE_FRAME_W, title)}</Pre>
+  <div class="panel-body"><slot /></div>
+  <Pre class="frame-wide">{buildPanelBottom(FRAME_W)}</Pre>
+  <Pre class="frame-narrow">{buildPanelBottom(MOBILE_FRAME_W)}</Pre>
+</section>

diff --git a/ooknet-design/src/components/Pa
nel/Panel.astro b/ooknet-design/src/componen
ts/Panel/Panel.astro
new file mode 100644
index 0000000..1f8b504
--- /dev/null
+++ b/ooknet-design/src/components/Panel/Pan
el.astro
@@ -0,0 +1,15 @@
+---
+import Pre from "../Pre/Pre.astro";
+import { FRAME_W, MOBILE_FRAME_W } from "..
/../lib/config";
+import { buildPanelTop, buildPanelBottom, t
ype Props } from "./Panel";
+import "./Panel.scss";
+
+const { title } = Astro.props as Props;
+---
+<section class="panel">
+  <Pre class="frame-wide">{buildPanelTop(FR
AME_W, title)}</Pre>
+  <Pre class="frame-narrow">{buildPanelTop(
MOBILE_FRAME_W, title)}</Pre>
+  <div class="panel-body"><slot /></div>
+  <Pre class="frame-wide">{buildPanelBottom
(FRAME_W)}</Pre>
+  <Pre class="frame-narrow">{buildPanelBott
om(MOBILE_FRAME_W)}</Pre>
+</section>
 

diff --git a/ooknet-design/src/components/Panel/Panel.scss b/ooknet-design/src/com
ponents/Panel/Panel.scss
new file mode 100644
index 0000000..b02f63b
--- /dev/null
+++ b/ooknet-design/src/components/Panel/Panel.scss
@@ -0,0 +1,24 @@
+.panel {
+  display: block;
+}
+
+// │ side rails - a glyph column pinned to each edge, clipped to the
+// body height, matching the build-time corners above and below.
+.panel-body {
+  position: relative;
+  padding: 0.2em 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│\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│\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; }
+}

diff --git a/ooknet-design/src/components/Pa
nel/Panel.scss b/ooknet-design/src/component
s/Panel/Panel.scss
new file mode 100644
index 0000000..b02f63b
--- /dev/null
+++ b/ooknet-design/src/components/Panel/Pan
el.scss
@@ -0,0 +1,24 @@
+.panel {
+  display: block;
+}
+
+// │ side rails - a glyph column pinned to 
each edge, clipped to the
+// body height, matching the build-time cor
ners above and below.
+.panel-body {
+  position: relative;
+  padding: 0.2em 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│\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│\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; }
+}
 

diff --git a/ooknet-design/src/components/Panel/Panel.test.ts b/ooknet-design/src/
components/Panel/Panel.test.ts
new file mode 100644
index 0000000..088cff4
--- /dev/null
+++ b/ooknet-design/src/components/Panel/Panel.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest";
+import { buildPanelTop, buildPanelBottom } from "./Panel";
+import { len } from "../../lib/ascii";
+
+describe("panel borders", () => {
+  it("spans the exact width with and without a title", () => {
+    for (const w of [86, 48]) {
+      expect(len(buildPanelTop(w))).toBe(w);
+      expect(len(buildPanelTop(w, "TELEMETRY"))).toBe(w);
+      expect(len(buildPanelBottom(w))).toBe(w);
+    }
+  });
+
+  it("seats the title after the corner", () => {
+    expect(buildPanelTop(20, "LOG")).toBe("┌─ LOG ────────────┐");
+  });
+
+  it("throws when the title cannot fit", () => {
+    expect(() => buildPanelTop(10, "A VERY LONG TITLE")).toThrow(/too long/);
+  });
+});

diff --git a/ooknet-design/src/components/Pa
nel/Panel.test.ts b/ooknet-design/src/compon
ents/Panel/Panel.test.ts
new file mode 100644
index 0000000..088cff4
--- /dev/null
+++ b/ooknet-design/src/components/Panel/Pan
el.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vites
t";
+import { buildPanelTop, buildPanelBottom } 
from "./Panel";
+import { len } from "../../lib/ascii";
+
+describe("panel borders", () => {
+  it("spans the exact width with and withou
t a title", () => {
+    for (const w of [86, 48]) {
+      expect(len(buildPanelTop(w))).toBe(w)
;
+      expect(len(buildPanelTop(w, "TELEMETR
Y"))).toBe(w);
+      expect(len(buildPanelBottom(w))).toBe
(w);
+    }
+  });
+
+  it("seats the title after the corner", ()
 => {
+    expect(buildPanelTop(20, "LOG")).toBe("
┌─ LOG ────────────┐");
+  });
+
+  it("throws when the title cannot fit", ()
 => {
+    expect(() => buildPanelTop(10, "A VERY 
LONG TITLE")).toThrow(/too long/);
+  });
+});
 

diff --git a/ooknet-design/src/components/Panel/Panel.ts b/ooknet-design/src/compo
nents/Panel/Panel.ts
new file mode 100644
index 0000000..d46d503
--- /dev/null
+++ b/ooknet-design/src/components/Panel/Panel.ts
@@ -0,0 +1,20 @@
+// Generic slotted box: build-time top/bottom borders, CSS glyph rails
+// down the sides (same trick as Dialog). Anything can live inside.
+import { G, checkGrid, len, rule } from "../../lib/ascii";
+
+export interface Props {
+  title?: string;
+}
+
+export function buildPanelTop(width: number, title?: string): string {
+  if (!title) return G.TL + rule(width - 2) + G.TR;
+  const label = ` ${title} `;
+  const tail = width - 3 - len(label);
+  if (tail < 1) {
+    throw new Error(`[ascii] panel title too long for ${width} cells: "${title}"`
);
+  }
+  return checkGrid(G.TL + G.H + label + rule(tail) + G.TR, width, "panel top");
+}
+
+export const buildPanelBottom = (width: number): string =>
+  G.BL + rule(width - 2) + G.BR;

diff --git a/ooknet-design/src/components/Pa
nel/Panel.ts b/ooknet-design/src/components/
Panel/Panel.ts
new file mode 100644
index 0000000..d46d503
--- /dev/null
+++ b/ooknet-design/src/components/Panel/Pan
el.ts
@@ -0,0 +1,20 @@
+// Generic slotted box: build-time top/bott
om borders, CSS glyph rails
+// down the sides (same trick as Dialog). A
nything can live inside.
+import { G, checkGrid, len, rule } from "..
/../lib/ascii";
+
+export interface Props {
+  title?: string;
+}
+
+export function buildPanelTop(width: number
, title?: string): string {
+  if (!title) return G.TL + rule(width - 2)
 + G.TR;
+  const label = ` ${title} `;
+  const tail = width - 3 - len(label);
+  if (tail < 1) {
+    throw new Error(`[ascii] panel title to
o long for ${width} cells: "${title}"`);
+  }
+  return checkGrid(G.TL + G.H + label + rul
e(tail) + G.TR, width, "panel top");
+}
+
+export const buildPanelBottom = (width: num
ber): string =>
+  G.BL + rule(width - 2) + G.BR;
 

diff --git a/ooknet-design/src/pages/components.astro b/ooknet-design/src/pages/co
mponents.astro
index 0f28dcb..d50fdcd 100644
--- a/ooknet-design/src/pages/components.astro
+++ b/ooknet-design/src/pages/components.astro
@@ -24,6 +24,9 @@ import type { TimelineEntry } from "../components/Timeline/Timel
ine";
 import Kbd from "../components/Kbd/Kbd.astro";
 import Gauge from "../components/Gauge/Gauge.astro";
 import Sparkline from "../components/Sparkline/Sparkline.astro";
+import BarChart from "../components/BarChart/BarChart.astro";
+import BigNumber from "../components/BigNumber/BigNumber.astro";
+import Panel from "../components/Panel/Panel.astro";
 import KeyValue from "../components/KeyValue/KeyValue.astro";
 import Banner from "../components/Banner/Banner.astro";
 import Steps from "../components/Steps/Steps.astro";
@@ -187,6 +190,29 @@ const FILED_TREE: TreeNode[] = [
   <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("BAR CHART", "BarChart.astro")} />
+  <Pre>{" "}</Pre>
+  <BarChart items={[
+    { label: "NORTH", value: 42 },
+    { label: "EAST", value: 17 },
+    { label: "SOUTH", value: 29 },
+    { label: "WEST", value: 8 },
+  ]} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("BIG NUMBER", "BigNumber.astro")} />
+  <Pre>{" "}</Pre>
+  <BigNumber value="14,230" label="KHZ - PRIMARY RELAY FREQUENCY" />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("PANEL", "Panel.astro")} />
+  <Pre>{" "}</Pre>
+  <Panel title="RELAY LOG">
+    <Pre>{pad("LAST SWEEP", LABEL_W)}<Sparkline values={[3, 5, 9, 4, 6, 12, 8, 7]
} /></Pre>
+    <Pre>{pad("SIGNAL", LABEL_W)}<Gauge value={47} threshold={70} /></Pre>
+  </Panel>
+  <Pre>{" "}</Pre>
+
   <Frame build={section("KEY VALUE", "KeyValue.astro")} />
   <Pre>{" "}</Pre>
   <KeyValue entries={[

diff --git a/ooknet-design/src/pages/compone
nts.astro b/ooknet-design/src/pages/componen
ts.astro
index 0f28dcb..d50fdcd 100644
--- a/ooknet-design/src/pages/components.ast
ro
+++ b/ooknet-design/src/pages/components.ast
ro
@@ -24,6 +24,9 @@ import type { TimelineEntr
y } from "../components/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 BarChart from "../components/BarChar
t/BarChart.astro";
+import BigNumber from "../components/BigNum
ber/BigNumber.astro";
+import Panel from "../components/Panel/Pane
l.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";
@@ -187,6 +190,29 @@ const FILED_TREE: TreeN
ode[] = [
   <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("BAR CHART", "BarCh
art.astro")} />
+  <Pre>{" "}</Pre>
+  <BarChart items={[
+    { label: "NORTH", value: 42 },
+    { label: "EAST", value: 17 },
+    { label: "SOUTH", value: 29 },
+    { label: "WEST", value: 8 },
+  ]} />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("BIG NUMBER", "BigN
umber.astro")} />
+  <Pre>{" "}</Pre>
+  <BigNumber value="14,230" label="KHZ - PR
IMARY RELAY FREQUENCY" />
+  <Pre>{" "}</Pre>
+
+  <Frame build={section("PANEL", "Panel.ast
ro")} />
+  <Pre>{" "}</Pre>
+  <Panel title="RELAY LOG">
+    <Pre>{pad("LAST SWEEP", LABEL_W)}<Spark
line values={[3, 5, 9, 4, 6, 12, 8, 7]} /></
Pre>
+    <Pre>{pad("SIGNAL", LABEL_W)}<Gauge val
ue={47} threshold={70} /></Pre>
+  </Panel>
+  <Pre>{" "}</Pre>
+
   <Frame build={section("KEY VALUE", "KeyVa
lue.astro")} />
   <Pre>{" "}</Pre>
   <KeyValue entries={[
 

diff --git a/ooknet-design/src/pages/status.astro b/ooknet-design/src/pages/status
.astro
index 231c1fe..5fb7c36 100644
--- a/ooknet-design/src/pages/status.astro
+++ b/ooknet-design/src/pages/status.astro
@@ -13,6 +13,9 @@ import Calendar from "../components/Calendar/Calendar.astro";
 import Timeline from "../components/Timeline/Timeline.astro";
 import type { TimelineEntry } from "../components/Timeline/Timeline";
 import Steps from "../components/Steps/Steps.astro";
+import BigNumber from "../components/BigNumber/BigNumber.astro";
+import BarChart from "../components/BarChart/BarChart.astro";
+import Panel from "../components/Panel/Panel.astro";
 import { rule, ruleD, row2, pad } from "../lib/ascii";
 import { published, byNoteNum } from "../lib/notes";
 
@@ -37,6 +40,15 @@ const marks = byDate
 const VOLUME_TARGET = 50;
 const capacity = (notes.length / VOLUME_TARGET) * 100;
 
+const tagFreq = new Map<string, number>();
+for (const n of notes) {
+  for (const t of n.data.meta.tags) tagFreq.set(t, (tagFreq.get(t) ?? 0) + 1);
+}
+const topTags = [...tagFreq.entries()]
+  .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+  .slice(0, 8)
+  .map(([label, value]) => ({ label: label.toUpperCase(), value }));
+
 const log: TimelineEntry[] = byDate.map((n) => ({
   date: n.data.published,
   title: `${n.data.note} FILED`,
@@ -50,8 +62,10 @@ const log: TimelineEntry[] = byDate.map((n) => ({
   <Frame build={(w) => [row2("SYSTEM TELEMETRY", `AS OF ${latestDate}`, w), ruleD
(w)].join("\n")} />
   <Pre>{" "}</Pre>
 
+  <BigNumber value={notes.length} label="NOTES ON FILE" />
+  <Pre>{" "}</Pre>
+
   <KeyValue entries={[
-    ["NOTES", `${notes.length} FILED`],
     ["WORDS", `${totalWords} TOTAL / ${Math.round(totalWords / notes.length)} AVG
`],
     ["TAGS", `${tagCount} DISTINCT`],
     ["LAST FILING", latestDate],
@@ -70,9 +84,14 @@ const log: TimelineEntry[] = byDate.map((n) => ({
   <Pre>{pad("WORDS/NOTE", LABEL_W)}<Sparkline values={words} width={40} />{`  ${M
ath.max(...words)} PEAK`}</Pre>
   <Pre>{" "}</Pre>
 
-  <Frame build={section("FILINGS", `${monthPrefix} marked`)} />
+  <Frame build={section("TAXONOMY", `notes per tag, top ${topTags.length}`)} />
   <Pre>{" "}</Pre>
-  <Calendar year={year} month={month} marks={marks} />
+  <BarChart items={topTags} />
+  <Pre>{" "}</Pre>
+
+  <Panel title={`FILINGS ${monthPrefix}`}>
+    <Calendar year={year} month={month} marks={marks} />
+  </Panel>
   <Pre>{" "}</Pre>
 
   <Frame build={section("FILING LOG", "most recent first")} />

diff --git a/ooknet-design/src/pages/status.
astro b/ooknet-design/src/pages/status.astro
index 231c1fe..5fb7c36 100644
--- a/ooknet-design/src/pages/status.astro
+++ b/ooknet-design/src/pages/status.astro
@@ -13,6 +13,9 @@ import Calendar from "../c
omponents/Calendar/Calendar.astro";
 import Timeline from "../components/Timelin
e/Timeline.astro";
 import type { TimelineEntry } from "../comp
onents/Timeline/Timeline";
 import Steps from "../components/Steps/Step
s.astro";
+import BigNumber from "../components/BigNum
ber/BigNumber.astro";
+import BarChart from "../components/BarChar
t/BarChart.astro";
+import Panel from "../components/Panel/Pane
l.astro";
 import { rule, ruleD, row2, pad } from "../
lib/ascii";
 import { published, byNoteNum } from "../li
b/notes";
 
@@ -37,6 +40,15 @@ const marks = byDate
 const VOLUME_TARGET = 50;
 const capacity = (notes.length / VOLUME_TAR
GET) * 100;
 
+const tagFreq = new Map<string, number>();
+for (const n of notes) {
+  for (const t of n.data.meta.tags) tagFreq
.set(t, (tagFreq.get(t) ?? 0) + 1);
+}
+const topTags = [...tagFreq.entries()]
+  .sort((a, b) => b[1] - a[1] || a[0].local
eCompare(b[0]))
+  .slice(0, 8)
+  .map(([label, value]) => ({ label: label.
toUpperCase(), value }));
+
 const log: TimelineEntry[] = byDate.map((n)
 => ({
   date: n.data.published,
   title: `${n.data.note} FILED`,
@@ -50,8 +62,10 @@ const log: TimelineEntry[
] = byDate.map((n) => ({
   <Frame build={(w) => [row2("SYSTEM TELEME
TRY", `AS OF ${latestDate}`, w), ruleD(w)].j
oin("\n")} />
   <Pre>{" "}</Pre>
 
+  <BigNumber value={notes.length} label="NO
TES ON FILE" />
+  <Pre>{" "}</Pre>
+
   <KeyValue entries={[
-    ["NOTES", `${notes.length} FILED`],
     ["WORDS", `${totalWords} TOTAL / ${Math
.round(totalWords / notes.length)} AVG`],
     ["TAGS", `${tagCount} DISTINCT`],
     ["LAST FILING", latestDate],
@@ -70,9 +84,14 @@ const log: TimelineEntry[
] = byDate.map((n) => ({
   <Pre>{pad("WORDS/NOTE", LABEL_W)}<Sparkli
ne values={words} width={40} />{`  ${Math.ma
x(...words)} PEAK`}</Pre>
   <Pre>{" "}</Pre>
 
-  <Frame build={section("FILINGS", `${month
Prefix} marked`)} />
+  <Frame build={section("TAXONOMY", `notes 
per tag, top ${topTags.length}`)} />
   <Pre>{" "}</Pre>
-  <Calendar year={year} month={month} marks
={marks} />
+  <BarChart items={topTags} />
+  <Pre>{" "}</Pre>
+
+  <Panel title={`FILINGS ${monthPrefix}`}>
+    <Calendar year={year} month={month} mar
ks={marks} />
+  </Panel>
   <Pre>{" "}</Pre>
 
   <Frame build={section("FILING LOG", "most
 recent first")} />
 
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET