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
┌─ ooknet-design/src/components/BarChart/BarChart.astro ─────────────────────┐
│ 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 })} /> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nents/BarChart/BarChart.astro ───┐
│ 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 })} /> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/BarChart/BarChart.test.ts ───────────────────┐
│ 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(""); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nts/BarChart/BarChart.test.ts ───┐
│ 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(""); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/BarChart/BarChart.ts ────────────────────────┐
│ 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"); │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...mponents/BarChart/BarChart.ts ───┐
│ 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"); │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/BigNumber/BigNumber.astro ───────────────────┐
│ 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>} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nts/BigNumber/BigNumber.astro ───┐
│ 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>} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/BigNumber/BigNumber.test.ts ─────────────────┐
│ 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/); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...s/BigNumber/BigNumber.test.ts ───┐
│ 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/); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/BigNumber/BigNumber.ts ──────────────────────┐
│ 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"); │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...onents/BigNumber/BigNumber.ts ───┐
│ 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"); │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Panel/Panel.astro ───────────────────────────┐
│ 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> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../components/Panel/Panel.astro ───┐
│ 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> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Panel/Panel.scss ────────────────────────────┐
│ 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; } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/components/Panel/Panel.scss ───┐
│ 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; } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Panel/Panel.test.ts ─────────────────────────┐
│ 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/); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...omponents/Panel/Panel.test.ts ───┐
│ 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/); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Panel/Panel.ts ──────────────────────────────┐
│ 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; │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...src/components/Panel/Panel.ts ───┐
│ 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; │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/pages/components.astro ─────────────────────────────────┐
│ 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={[ │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...gn/src/pages/components.astro ───┐
│ 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={[ │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/pages/status.astro ─────────────────────────────────────┐
│ 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")} /> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...design/src/pages/status.astro ───┐
│ 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