HASH 025a976f2e76
DATE 2026-07-17
SUBJECT web: gauge, sparkline, keyvalue, banner, steps, calendar, pagination, kbd,
switch, stepper, and dialog
FILES 36 CHANGED HASH 025a976f2e76
DATE 2026-07-17
SUBJECT web: gauge, sparkline, keyvalue,
banner, steps, calendar, pagination,
kbd, switch, stepper, and dialog
FILES 36 CHANGED
┌─ ooknet-design/src/components/Banner/Banner.astro ─────────────────────────┐
│ diff --git a/ooknet-design/src/components/Banner/Banner.astro b/ooknet-design/src/ │
│ components/Banner/Banner.astro │
│ new file mode 100644 │
│ index 0000000..1fdc698 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Banner/Banner.astro │
│ @@ -0,0 +1,11 @@ │
│ +--- │
│ +import Frame from "../Frame/Frame.astro"; │
│ +import { buildBanner, BANNER_LABEL, type Props } from "./Banner"; │
│ +import "./Banner.scss"; │
│ + │
│ +const { variant = "notice", message } = Astro.props as Props; │
│ +--- │
│ +<div class="banner" role={variant === "notice" ? "note" : "alert"}> │
│ + <Frame class={`banner-frame banner-${variant}`} build={(w) => buildBanner(varia │
│ nt, message, w)} /> │
│ + <span class="sr-only">{BANNER_LABEL[variant]}: {message}</span> │
│ +</div> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...omponents/Banner/Banner.astro ───┐
│ diff --git a/ooknet-design/src/components/Ba │
│ nner/Banner.astro b/ooknet-design/src/compon │
│ ents/Banner/Banner.astro │
│ new file mode 100644 │
│ index 0000000..1fdc698 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Banner/Ba │
│ nner.astro │
│ @@ -0,0 +1,11 @@ │
│ +--- │
│ +import Frame from "../Frame/Frame.astro"; │
│ +import { buildBanner, BANNER_LABEL, type Pr │
│ ops } from "./Banner"; │
│ +import "./Banner.scss"; │
│ + │
│ +const { variant = "notice", message } = Ast │
│ ro.props as Props; │
│ +--- │
│ +<div class="banner" role={variant === "noti │
│ ce" ? "note" : "alert"}> │
│ + <Frame class={`banner-frame banner-${vari │
│ ant}`} build={(w) => buildBanner(variant, me │
│ ssage, w)} /> │
│ + <span class="sr-only">{BANNER_LABEL[varia │
│ nt]}: {message}</span> │
│ +</div> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Banner/Banner.scss ──────────────────────────┐
│ diff --git a/ooknet-design/src/components/Banner/Banner.scss b/ooknet-design/src/c │
│ omponents/Banner/Banner.scss │
│ new file mode 100644 │
│ index 0000000..b3f0be3 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Banner/Banner.scss │
│ @@ -0,0 +1,12 @@ │
│ +.banner { │
│ + margin: 0.8em 0; │
│ + │
│ + pre.ascii.banner-frame { │
│ + // The sr-only line carries the content for screen readers. │
│ + user-select: text; │
│ + } │
│ + │
│ + pre.ascii.banner-error { │
│ + font-weight: 700; │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...components/Banner/Banner.scss ───┐
│ diff --git a/ooknet-design/src/components/Ba │
│ nner/Banner.scss b/ooknet-design/src/compone │
│ nts/Banner/Banner.scss │
│ new file mode 100644 │
│ index 0000000..b3f0be3 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Banner/Ba │
│ nner.scss │
│ @@ -0,0 +1,12 @@ │
│ +.banner { │
│ + margin: 0.8em 0; │
│ + │
│ + pre.ascii.banner-frame { │
│ + // The sr-only line carries the content │
│ for screen readers. │
│ + user-select: text; │
│ + } │
│ + │
│ + pre.ascii.banner-error { │
│ + font-weight: 700; │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Banner/Banner.test.ts ───────────────────────┐
│ diff --git a/ooknet-design/src/components/Banner/Banner.test.ts b/ooknet-design/sr │
│ c/components/Banner/Banner.test.ts │
│ new file mode 100644 │
│ index 0000000..5156618 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Banner/Banner.test.ts │
│ @@ -0,0 +1,21 @@ │
│ +import { describe, expect, it } from "vitest"; │
│ +import { buildBanner } from "./Banner"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +describe("buildBanner", () => { │
│ + it("renders every line at the frame width", () => { │
│ + for (const w of [48, 86]) { │
│ + const out = buildBanner("warning", "frequency drift detected on channel bra │
│ vo", w); │
│ + for (const ln of out.split("\n")) expect(len(ln)).toBe(w); │
│ + } │
│ + }); │
│ + │
│ + it("embeds the variant label in the top rule", () => { │
│ + expect(buildBanner("error", "x", 48).split("\n")[0]).toContain("─ ERROR ─"); │
│ + }); │
│ + │
│ + it("wraps long messages", () => { │
│ + const out = buildBanner("notice", "word ".repeat(30).trim(), 48); │
│ + expect(out.split("\n").length).toBeGreaterThan(4); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ponents/Banner/Banner.test.ts ───┐
│ diff --git a/ooknet-design/src/components/Ba │
│ nner/Banner.test.ts b/ooknet-design/src/comp │
│ onents/Banner/Banner.test.ts │
│ new file mode 100644 │
│ index 0000000..5156618 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Banner/Ba │
│ nner.test.ts │
│ @@ -0,0 +1,21 @@ │
│ +import { describe, expect, it } from "vites │
│ t"; │
│ +import { buildBanner } from "./Banner"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +describe("buildBanner", () => { │
│ + it("renders every line at the frame width │
│ ", () => { │
│ + for (const w of [48, 86]) { │
│ + const out = buildBanner("warning", "f │
│ requency drift detected on channel bravo", w │
│ ); │
│ + for (const ln of out.split("\n")) exp │
│ ect(len(ln)).toBe(w); │
│ + } │
│ + }); │
│ + │
│ + it("embeds the variant label in the top r │
│ ule", () => { │
│ + expect(buildBanner("error", "x", 48).sp │
│ lit("\n")[0]).toContain("─ ERROR ─"); │
│ + }); │
│ + │
│ + it("wraps long messages", () => { │
│ + const out = buildBanner("notice", "word │
│ ".repeat(30).trim(), 48); │
│ + expect(out.split("\n").length).toBeGrea │
│ terThan(4); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Banner/Banner.ts ────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Banner/Banner.ts b/ooknet-design/src/com │
│ ponents/Banner/Banner.ts │
│ new file mode 100644 │
│ index 0000000..889af4b │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Banner/Banner.ts │
│ @@ -0,0 +1,27 @@ │
│ +// Page-level notice in callout chrome - the component sibling of the │
│ +// markdown [!NOTE] transform, for use outside prose. │
│ +import { G, checkGrid, len, pad, wrap } from "../../lib/ascii"; │
│ + │
│ +export type BannerVariant = "notice" | "warning" | "error"; │
│ + │
│ +export interface Props { │
│ + variant?: BannerVariant; │
│ + message: string; │
│ +} │
│ + │
│ +export const BANNER_LABEL: Record<BannerVariant, string> = { │
│ + notice: "NOTICE", │
│ + warning: "WARNING", │
│ + error: "ERROR", │
│ +}; │
│ + │
│ +export function buildBanner(variant: BannerVariant, message: string, width: numbe │
│ r): string { │
│ + const label = BANNER_LABEL[variant]; │
│ + const inner = width - 4; │
│ + const lines = [ │
│ + `${G.TL}${G.H} ${label} ${G.H.repeat(Math.max(0, width - 5 - len(label)))}${G │
│ .TR}`, │
│ + ...wrap(message, inner).split("\n").map((ln) => `${G.V} ${pad(ln, inner)} ${G │
│ .V}`), │
│ + `${G.BL}${G.H.repeat(width - 2)}${G.BR}`, │
│ + ]; │
│ + return checkGrid(lines.join("\n"), width, `banner(${label})`); │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/components/Banner/Banner.ts ───┐
│ diff --git a/ooknet-design/src/components/Ba │
│ nner/Banner.ts b/ooknet-design/src/component │
│ s/Banner/Banner.ts │
│ new file mode 100644 │
│ index 0000000..889af4b │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Banner/Ba │
│ nner.ts │
│ @@ -0,0 +1,27 @@ │
│ +// Page-level notice in callout chrome - th │
│ e component sibling of the │
│ +// markdown [!NOTE] transform, for use outs │
│ ide prose. │
│ +import { G, checkGrid, len, pad, wrap } fro │
│ m "../../lib/ascii"; │
│ + │
│ +export type BannerVariant = "notice" | "war │
│ ning" | "error"; │
│ + │
│ +export interface Props { │
│ + variant?: BannerVariant; │
│ + message: string; │
│ +} │
│ + │
│ +export const BANNER_LABEL: Record<BannerVar │
│ iant, string> = { │
│ + notice: "NOTICE", │
│ + warning: "WARNING", │
│ + error: "ERROR", │
│ +}; │
│ + │
│ +export function buildBanner(variant: Banner │
│ Variant, message: string, width: number): st │
│ ring { │
│ + const label = BANNER_LABEL[variant]; │
│ + const inner = width - 4; │
│ + const lines = [ │
│ + `${G.TL}${G.H} ${label} ${G.H.repeat(Ma │
│ th.max(0, width - 5 - len(label)))}${G.TR}`, │
│ + ...wrap(message, inner).split("\n").map │
│ ((ln) => `${G.V} ${pad(ln, inner)} ${G.V}`), │
│ + `${G.BL}${G.H.repeat(width - 2)}${G.BR} │
│ `, │
│ + ]; │
│ + return checkGrid(lines.join("\n"), width, │
│ `banner(${label})`); │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Calendar/Calendar.astro ─────────────────────┐
│ diff --git a/ooknet-design/src/components/Calendar/Calendar.astro b/ooknet-design/ │
│ src/components/Calendar/Calendar.astro │
│ new file mode 100644 │
│ index 0000000..dd8f915 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Calendar/Calendar.astro │
│ @@ -0,0 +1,7 @@ │
│ +--- │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import { buildCalendar, type Props } from "./Calendar"; │
│ + │
│ +const { year, month, marks } = Astro.props as Props; │
│ +--- │
│ +<Pre class="calendar">{buildCalendar(year, month, marks)}</Pre> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nents/Calendar/Calendar.astro ───┐
│ diff --git a/ooknet-design/src/components/Ca │
│ lendar/Calendar.astro b/ooknet-design/src/co │
│ mponents/Calendar/Calendar.astro │
│ new file mode 100644 │
│ index 0000000..dd8f915 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Calendar/ │
│ Calendar.astro │
│ @@ -0,0 +1,7 @@ │
│ +--- │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import { buildCalendar, type Props } from " │
│ ./Calendar"; │
│ + │
│ +const { year, month, marks } = Astro.props │
│ as Props; │
│ +--- │
│ +<Pre class="calendar">{buildCalendar(year, │
│ month, marks)}</Pre> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Calendar/Calendar.test.ts ───────────────────┐
│ diff --git a/ooknet-design/src/components/Calendar/Calendar.test.ts b/ooknet-desig │
│ n/src/components/Calendar/Calendar.test.ts │
│ new file mode 100644 │
│ index 0000000..d185992 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Calendar/Calendar.test.ts │
│ @@ -0,0 +1,34 @@ │
│ +import { describe, expect, it } from "vitest"; │
│ +import { buildCalendar, CAL_W } from "./Calendar"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +describe("buildCalendar", () => { │
│ + it("lays out July 2026 starting on Wednesday", () => { │
│ + const lines = buildCalendar(2026, 7).split("\n"); │
│ + expect(lines[0].trim()).toBe("JULY 2026"); │
│ + expect(lines[1]).toBe("MO TU WE TH FR SA SU"); │
│ + expect(lines[2]).toBe(" 1 2 3 4 5"); │
│ + expect(lines.at(-1)).toBe("27 28 29 30 31"); │
│ + }); │
│ + │
│ + it("never exceeds the calendar width", () => { │
│ + for (const m of [1, 2, 6, 12]) { │
│ + for (const ln of buildCalendar(2026, m).split("\n")) { │
│ + expect(len(ln)).toBeLessThanOrEqual(CAL_W); │
│ + } │
│ + } │
│ + }); │
│ + │
│ + it("marks days with a * in the separator column", () => { │
│ + const out = buildCalendar(2026, 7, [17]); │
│ + expect(out).toContain("*17"); │
│ + // grid alignment unchanged: the 16 stays in place │
│ + const line = out.split("\n").find((l) => l.includes("17"))!; │
│ + expect(line.indexOf("17")).toBe(line.indexOf("16") + 3); │
│ + }); │
│ + │
│ + it("handles a February that starts on Monday", () => { │
│ + const lines = buildCalendar(2027, 2).split("\n"); │
│ + expect(lines[2]).toBe(" 1 2 3 4 5 6 7"); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nts/Calendar/Calendar.test.ts ───┐
│ diff --git a/ooknet-design/src/components/Ca │
│ lendar/Calendar.test.ts b/ooknet-design/src/ │
│ components/Calendar/Calendar.test.ts │
│ new file mode 100644 │
│ index 0000000..d185992 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Calendar/ │
│ Calendar.test.ts │
│ @@ -0,0 +1,34 @@ │
│ +import { describe, expect, it } from "vites │
│ t"; │
│ +import { buildCalendar, CAL_W } from "./Cal │
│ endar"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +describe("buildCalendar", () => { │
│ + it("lays out July 2026 starting on Wednes │
│ day", () => { │
│ + const lines = buildCalendar(2026, 7).sp │
│ lit("\n"); │
│ + expect(lines[0].trim()).toBe("JULY 2026 │
│ "); │
│ + expect(lines[1]).toBe("MO TU WE TH FR S │
│ A SU"); │
│ + expect(lines[2]).toBe(" 1 2 3 │
│ 4 5"); │
│ + expect(lines.at(-1)).toBe("27 28 29 30 │
│ 31"); │
│ + }); │
│ + │
│ + it("never exceeds the calendar width", () │
│ => { │
│ + for (const m of [1, 2, 6, 12]) { │
│ + for (const ln of buildCalendar(2026, │
│ m).split("\n")) { │
│ + expect(len(ln)).toBeLessThanOrEqual │
│ (CAL_W); │
│ + } │
│ + } │
│ + }); │
│ + │
│ + it("marks days with a * in the separator │
│ column", () => { │
│ + const out = buildCalendar(2026, 7, [17] │
│ ); │
│ + expect(out).toContain("*17"); │
│ + // grid alignment unchanged: the 16 sta │
│ ys in place │
│ + const line = out.split("\n").find((l) = │
│ > l.includes("17"))!; │
│ + expect(line.indexOf("17")).toBe(line.in │
│ dexOf("16") + 3); │
│ + }); │
│ + │
│ + it("handles a February that starts on Mon │
│ day", () => { │
│ + const lines = buildCalendar(2027, 2).sp │
│ lit("\n"); │
│ + expect(lines[2]).toBe(" 1 2 3 4 5 │
│ 6 7"); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Calendar/Calendar.ts ────────────────────────┐
│ diff --git a/ooknet-design/src/components/Calendar/Calendar.ts b/ooknet-design/src │
│ /components/Calendar/Calendar.ts │
│ new file mode 100644 │
│ index 0000000..6e4906e │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Calendar/Calendar.ts │
│ @@ -0,0 +1,53 @@ │
│ +// Classic cal(1) month grid, Monday-first, 20 cells wide. Marked days │
│ +// get a * prefix in the separator column so the grid never shifts. │
│ +import { center, checkGrid } from "../../lib/ascii"; │
│ + │
│ +export interface Props { │
│ + year: number; │
│ + /** 1-12. */ │
│ + month: number; │
│ + marks?: number[]; │
│ +} │
│ + │
│ +export const CAL_W = 20; │
│ + │
│ +const MONTHS = [ │
│ + "JANUARY", "FEBRUARY", "MARCH", "APRIL", "MAY", "JUNE", │
│ + "JULY", "AUGUST", "SEPTEMBER", "OCTOBER", "NOVEMBER", "DECEMBER", │
│ +]; │
│ + │
│ +export function buildCalendar(year: number, month: number, marks: number[] = []): │
│ string { │
│ + const marked = new Set(marks); │
│ + // Monday-first offset of the 1st. │
│ + const offset = (new Date(Date.UTC(year, month - 1, 1)).getUTCDay() + 6) % 7; │
│ + const days = new Date(Date.UTC(year, month, 0)).getUTCDate(); │
│ + │
│ + const lines = [center(`${MONTHS[month - 1]} ${year}`, CAL_W), "MO TU WE TH FR S │
│ A SU"]; │
│ + │
│ + let week: string[] = Array.from({ length: offset }, () => " "); │
│ + for (let d = 1; d <= days; d++) { │
│ + week.push(String(d).padStart(2)); │
│ + if (week.length === 7) { │
│ + lines.push(weekLine(week, marked)); │
│ + week = []; │
│ + } │
│ + } │
│ + if (week.length) { │
│ + while (week.length < 7) week.push(" "); │
│ + lines.push(weekLine(week, marked)); │
│ + } │
│ + │
│ + return checkGrid(lines.join("\n"), CAL_W, "calendar"); │
│ +} │
│ + │
│ +function weekLine(cells: string[], marked: Set<number>): string { │
│ + let out = ""; │
│ + cells.forEach((c, i) => { │
│ + const day = parseInt(c, 10); │
│ + out += (i === 0 ? "" : marked.has(day) ? "*" : " ") + c; │
│ + }); │
│ + // A marked Monday has no separator column; flag it inside the cell. │
│ + const first = parseInt(cells[0], 10); │
│ + if (marked.has(first) && first < 10) out = "*" + out.slice(1); │
│ + return out.trimEnd(); │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...mponents/Calendar/Calendar.ts ───┐
│ diff --git a/ooknet-design/src/components/Ca │
│ lendar/Calendar.ts b/ooknet-design/src/compo │
│ nents/Calendar/Calendar.ts │
│ new file mode 100644 │
│ index 0000000..6e4906e │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Calendar/ │
│ Calendar.ts │
│ @@ -0,0 +1,53 @@ │
│ +// Classic cal(1) month grid, Monday-first, │
│ 20 cells wide. Marked days │
│ +// get a * prefix in the separator column s │
│ o the grid never shifts. │
│ +import { center, checkGrid } from "../../li │
│ b/ascii"; │
│ + │
│ +export interface Props { │
│ + year: number; │
│ + /** 1-12. */ │
│ + month: number; │
│ + marks?: number[]; │
│ +} │
│ + │
│ +export const CAL_W = 20; │
│ + │
│ +const MONTHS = [ │
│ + "JANUARY", "FEBRUARY", "MARCH", "APRIL", │
│ "MAY", "JUNE", │
│ + "JULY", "AUGUST", "SEPTEMBER", "OCTOBER", │
│ "NOVEMBER", "DECEMBER", │
│ +]; │
│ + │
│ +export function buildCalendar(year: number, │
│ month: number, marks: number[] = []): strin │
│ g { │
│ + const marked = new Set(marks); │
│ + // Monday-first offset of the 1st. │
│ + const offset = (new Date(Date.UTC(year, m │
│ onth - 1, 1)).getUTCDay() + 6) % 7; │
│ + const days = new Date(Date.UTC(year, mont │
│ h, 0)).getUTCDate(); │
│ + │
│ + const lines = [center(`${MONTHS[month - 1 │
│ ]} ${year}`, CAL_W), "MO TU WE TH FR SA SU"] │
│ ; │
│ + │
│ + let week: string[] = Array.from({ length: │
│ offset }, () => " "); │
│ + for (let d = 1; d <= days; d++) { │
│ + week.push(String(d).padStart(2)); │
│ + if (week.length === 7) { │
│ + lines.push(weekLine(week, marked)); │
│ + week = []; │
│ + } │
│ + } │
│ + if (week.length) { │
│ + while (week.length < 7) week.push(" ") │
│ ; │
│ + lines.push(weekLine(week, marked)); │
│ + } │
│ + │
│ + return checkGrid(lines.join("\n"), CAL_W, │
│ "calendar"); │
│ +} │
│ + │
│ +function weekLine(cells: string[], marked: │
│ Set<number>): string { │
│ + let out = ""; │
│ + cells.forEach((c, i) => { │
│ + const day = parseInt(c, 10); │
│ + out += (i === 0 ? "" : marked.has(day) │
│ ? "*" : " ") + c; │
│ + }); │
│ + // A marked Monday has no separator colum │
│ n; flag it inside the cell. │
│ + const first = parseInt(cells[0], 10); │
│ + if (marked.has(first) && first < 10) out │
│ = "*" + out.slice(1); │
│ + return out.trimEnd(); │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Dialog/Dialog.astro ─────────────────────────┐
│ diff --git a/ooknet-design/src/components/Dialog/Dialog.astro b/ooknet-design/src/ │
│ components/Dialog/Dialog.astro │
│ new file mode 100644 │
│ index 0000000..16b116e │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Dialog/Dialog.astro │
│ @@ -0,0 +1,35 @@ │
│ +--- │
│ +// Native <dialog> in a double-line frame. Renders once at the mobile │
│ +// width so it fits any viewport. Open with any element carrying │
│ +// data-dialog-open="<id>"; [ CLOSE ] and Esc dismiss it. │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import Button from "../Button/Button.astro"; │
│ +import { MOBILE_FRAME_W } from "../../lib/config"; │
│ +import { buildDialogTop, buildDialogBottom, type Props } from "./Dialog"; │
│ +import "./Dialog.scss"; │
│ + │
│ +const { id, title, width = MOBILE_FRAME_W } = Astro.props as Props; │
│ +--- │
│ +<dialog class="dialog" id={id} aria-label={title}> │
│ + <Pre>{buildDialogTop(title, width)}</Pre> │
│ + <div class="dialog-body"> │
│ + <slot /> │
│ + </div> │
│ + <div class="dialog-footer"><span data-dialog-close><Button label="CLOSE" /></sp │
│ an></div> │
│ + <Pre>{buildDialogBottom(width)}</Pre> │
│ +</dialog> │
│ + │
│ +<script> │
│ + document.addEventListener("click", (e) => { │
│ + const target = e.target as HTMLElement; │
│ + │
│ + const opener = target.closest<HTMLElement>("[data-dialog-open]"); │
│ + if (opener) { │
│ + document.querySelector<HTMLDialogElement>(`#${opener.dataset.dialogOpen}`)? │
│ .showModal(); │
│ + return; │
│ + } │
│ + │
│ + const closer = target.closest<HTMLElement>("[data-dialog-close]"); │
│ + if (closer) closer.closest("dialog")?.close(); │
│ + }); │
│ +</script> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...omponents/Dialog/Dialog.astro ───┐
│ diff --git a/ooknet-design/src/components/Di │
│ alog/Dialog.astro b/ooknet-design/src/compon │
│ ents/Dialog/Dialog.astro │
│ new file mode 100644 │
│ index 0000000..16b116e │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Dialog/Di │
│ alog.astro │
│ @@ -0,0 +1,35 @@ │
│ +--- │
│ +// Native <dialog> in a double-line frame. │
│ Renders once at the mobile │
│ +// width so it fits any viewport. Open with │
│ any element carrying │
│ +// data-dialog-open="<id>"; [ CLOSE ] and E │
│ sc dismiss it. │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import Button from "../Button/Button.astro" │
│ ; │
│ +import { MOBILE_FRAME_W } from "../../lib/c │
│ onfig"; │
│ +import { buildDialogTop, buildDialogBottom, │
│ type Props } from "./Dialog"; │
│ +import "./Dialog.scss"; │
│ + │
│ +const { id, title, width = MOBILE_FRAME_W } │
│ = Astro.props as Props; │
│ +--- │
│ +<dialog class="dialog" id={id} aria-label={ │
│ title}> │
│ + <Pre>{buildDialogTop(title, width)}</Pre> │
│ + <div class="dialog-body"> │
│ + <slot /> │
│ + </div> │
│ + <div class="dialog-footer"><span data-dia │
│ log-close><Button label="CLOSE" /></span></d │
│ iv> │
│ + <Pre>{buildDialogBottom(width)}</Pre> │
│ +</dialog> │
│ + │
│ +<script> │
│ + document.addEventListener("click", (e) => │
│ { │
│ + const target = e.target as HTMLElement; │
│ + │
│ + const opener = target.closest<HTMLEleme │
│ nt>("[data-dialog-open]"); │
│ + if (opener) { │
│ + document.querySelector<HTMLDialogElem │
│ ent>(`#${opener.dataset.dialogOpen}`)?.showM │
│ odal(); │
│ + return; │
│ + } │
│ + │
│ + const closer = target.closest<HTMLEleme │
│ nt>("[data-dialog-close]"); │
│ + if (closer) closer.closest("dialog")?.c │
│ lose(); │
│ + }); │
│ +</script> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Dialog/Dialog.scss ──────────────────────────┐
│ diff --git a/ooknet-design/src/components/Dialog/Dialog.scss b/ooknet-design/src/c │
│ omponents/Dialog/Dialog.scss │
│ new file mode 100644 │
│ index 0000000..edc8ebb │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Dialog/Dialog.scss │
│ @@ -0,0 +1,48 @@ │
│ +.dialog { │
│ + border: 0; │
│ + padding: 0; │
│ + background: var(--paper); │
│ + color: var(--ink); │
│ + font-family: var(--mono); │
│ + font-size: var(--fs); │
│ + line-height: var(--lh); │
│ + │
│ + // pre.ascii is frame-width by default; the dialog is narrower. │
│ + pre.ascii { │
│ + width: auto; │
│ + } │
│ + │
│ + &::backdrop { │
│ + background: rgba(0, 0, 0, 0.45); │
│ + } │
│ + │
│ + // ║ side rails, same glyph-column trick as callouts. │
│ + .dialog-body, │
│ + .dialog-footer { │
│ + position: relative; │
│ + padding: 0 calc(var(--cell-w) * 3); │
│ + │
│ + &::before, │
│ + &::after { │
│ + content: "║\A║\A║\A║\A║\A║\A║\A║\A║\A║\A║\A║\A║\A║\A║\A║\A║\A║\A║\A║\A║\A║\ │
│ A║\A║\A║\A║\A║\A║\A║\A║"; │
│ + position: absolute; │
│ + top: 0; │
│ + bottom: 0; │
│ + font-family: var(--mono-frame); │
│ + white-space: pre; │
│ + overflow: hidden; │
│ + color: var(--ink); │
│ + } │
│ + &::before { left: 0; } │
│ + &::after { right: 0; } │
│ + } │
│ + │
│ + .dialog-body { │
│ + margin: 0.4em 0; │
│ + } │
│ + │
│ + .dialog-footer { │
│ + padding-top: 0.2em; │
│ + padding-bottom: 0.4em; │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...components/Dialog/Dialog.scss ───┐
│ diff --git a/ooknet-design/src/components/Di │
│ alog/Dialog.scss b/ooknet-design/src/compone │
│ nts/Dialog/Dialog.scss │
│ new file mode 100644 │
│ index 0000000..edc8ebb │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Dialog/Di │
│ alog.scss │
│ @@ -0,0 +1,48 @@ │
│ +.dialog { │
│ + border: 0; │
│ + padding: 0; │
│ + background: var(--paper); │
│ + color: var(--ink); │
│ + font-family: var(--mono); │
│ + font-size: var(--fs); │
│ + line-height: var(--lh); │
│ + │
│ + // pre.ascii is frame-width by default; t │
│ he dialog is narrower. │
│ + pre.ascii { │
│ + width: auto; │
│ + } │
│ + │
│ + &::backdrop { │
│ + background: rgba(0, 0, 0, 0.45); │
│ + } │
│ + │
│ + // ║ side rails, same glyph-column trick │
│ as callouts. │
│ + .dialog-body, │
│ + .dialog-footer { │
│ + position: relative; │
│ + padding: 0 calc(var(--cell-w) * 3); │
│ + │
│ + &::before, │
│ + &::after { │
│ + content: "║\A║\A║\A║\A║\A║\A║\A║\A║\A │
│ ║\A║\A║\A║\A║\A║\A║\A║\A║\A║\A║\A║\A║\A║\A║\ │
│ A║\A║\A║\A║\A║\A║"; │
│ + position: absolute; │
│ + top: 0; │
│ + bottom: 0; │
│ + font-family: var(--mono-frame); │
│ + white-space: pre; │
│ + overflow: hidden; │
│ + color: var(--ink); │
│ + } │
│ + &::before { left: 0; } │
│ + &::after { right: 0; } │
│ + } │
│ + │
│ + .dialog-body { │
│ + margin: 0.4em 0; │
│ + } │
│ + │
│ + .dialog-footer { │
│ + padding-top: 0.2em; │
│ + padding-bottom: 0.4em; │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Dialog/Dialog.ts ────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Dialog/Dialog.ts b/ooknet-design/src/com │
│ ponents/Dialog/Dialog.ts │
│ new file mode 100644 │
│ index 0000000..a430085 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Dialog/Dialog.ts │
│ @@ -0,0 +1,23 @@ │
│ +// Double-line frame rules for the modal - ═ distinguishes the overlay │
│ +// from single-ruled page content. │
│ +import { checkGrid, len } from "../../lib/ascii"; │
│ + │
│ +export interface Props { │
│ + id: string; │
│ + title: string; │
│ + /** Frame width in cells; defaults to the mobile frame so it fits any │
│ + * viewport. */ │
│ + width?: number; │
│ +} │
│ + │
│ +export function buildDialogTop(title: string, width: number): string { │
│ + return checkGrid( │
│ + `╔═ ${title} ${"═".repeat(Math.max(0, width - 5 - len(title)))}╗`, │
│ + width, │
│ + `dialog(${title})`, │
│ + ); │
│ +} │
│ + │
│ +export function buildDialogBottom(width: number): string { │
│ + return `╚${"═".repeat(width - 2)}╝`; │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/components/Dialog/Dialog.ts ───┐
│ diff --git a/ooknet-design/src/components/Di │
│ alog/Dialog.ts b/ooknet-design/src/component │
│ s/Dialog/Dialog.ts │
│ new file mode 100644 │
│ index 0000000..a430085 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Dialog/Di │
│ alog.ts │
│ @@ -0,0 +1,23 @@ │
│ +// Double-line frame rules for the modal - │
│ ═ distinguishes the overlay │
│ +// from single-ruled page content. │
│ +import { checkGrid, len } from "../../lib/a │
│ scii"; │
│ + │
│ +export interface Props { │
│ + id: string; │
│ + title: string; │
│ + /** Frame width in cells; defaults to the │
│ mobile frame so it fits any │
│ + * viewport. */ │
│ + width?: number; │
│ +} │
│ + │
│ +export function buildDialogTop(title: strin │
│ g, width: number): string { │
│ + return checkGrid( │
│ + `╔═ ${title} ${"═".repeat(Math.max(0, w │
│ idth - 5 - len(title)))}╗`, │
│ + width, │
│ + `dialog(${title})`, │
│ + ); │
│ +} │
│ + │
│ +export function buildDialogBottom(width: nu │
│ mber): string { │
│ + return `╚${"═".repeat(width - 2)}╝`; │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Gauge/Gauge.astro ───────────────────────────┐
│ diff --git a/ooknet-design/src/components/Gauge/Gauge.astro b/ooknet-design/src/co │
│ mponents/Gauge/Gauge.astro │
│ new file mode 100644 │
│ index 0000000..a0519c8 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Gauge/Gauge.astro │
│ @@ -0,0 +1,6 @@ │
│ +--- │
│ +import { buildGauge, type Props } from "./Gauge"; │
│ + │
│ +const { value, threshold, cells, low, high } = Astro.props as Props; │
│ +--- │
│ +<span class="gauge" role="meter" aria-valuenow={Math.round(value)} aria-valuemin= │
│ "0" aria-valuemax="100">{buildGauge(value, { cells, threshold, low, high })}</span │
│ > │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../components/Gauge/Gauge.astro ───┐
│ diff --git a/ooknet-design/src/components/Ga │
│ uge/Gauge.astro b/ooknet-design/src/componen │
│ ts/Gauge/Gauge.astro │
│ new file mode 100644 │
│ index 0000000..a0519c8 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Gauge/Gau │
│ ge.astro │
│ @@ -0,0 +1,6 @@ │
│ +--- │
│ +import { buildGauge, type Props } from "./G │
│ auge"; │
│ + │
│ +const { value, threshold, cells, low, high │
│ } = Astro.props as Props; │
│ +--- │
│ +<span class="gauge" role="meter" aria-value │
│ now={Math.round(value)} aria-valuemin="0" ar │
│ ia-valuemax="100">{buildGauge(value, { cells │
│ , threshold, low, high })}</span> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Gauge/Gauge.test.ts ─────────────────────────┐
│ diff --git a/ooknet-design/src/components/Gauge/Gauge.test.ts b/ooknet-design/src/ │
│ components/Gauge/Gauge.test.ts │
│ new file mode 100644 │
│ index 0000000..67001d5 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Gauge/Gauge.test.ts │
│ @@ -0,0 +1,28 @@ │
│ +import { describe, expect, it } from "vitest"; │
│ +import { buildGauge, gaugeWidth } from "./Gauge"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +describe("buildGauge", () => { │
│ + it("renders the exact declared width", () => { │
│ + for (const v of [0, 47, 100]) { │
│ + expect(len(buildGauge(v, { cells: 10 }))).toBe(gaugeWidth(10)); │
│ + expect(len(buildGauge(v, { cells: 10, threshold: 70 }))).toBe(gaugeWidth(10 │
│ , v >= 70 ? "CRITICAL" : "NOMINAL")); │
│ + } │
│ + }); │
│ + │
│ + it("places the threshold marker inside the bar", () => { │
│ + const g = buildGauge(30, { cells: 10, threshold: 70 }); │
│ + expect([...g][8]).toBe("│"); │
│ + }); │
│ + │
│ + it("flips the status word at the threshold", () => { │
│ + expect(buildGauge(47, { threshold: 70 })).toContain("NOMINAL"); │
│ + expect(buildGauge(85, { threshold: 70 })).toContain("CRITICAL"); │
│ + }); │
│ + │
│ + it("omits marker and status without a threshold", () => { │
│ + const g = buildGauge(47, { cells: 10 }); │
│ + expect(g).not.toContain("│"); │
│ + expect(g.trimEnd().endsWith("%")).toBe(true); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...omponents/Gauge/Gauge.test.ts ───┐
│ diff --git a/ooknet-design/src/components/Ga │
│ uge/Gauge.test.ts b/ooknet-design/src/compon │
│ ents/Gauge/Gauge.test.ts │
│ new file mode 100644 │
│ index 0000000..67001d5 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Gauge/Gau │
│ ge.test.ts │
│ @@ -0,0 +1,28 @@ │
│ +import { describe, expect, it } from "vites │
│ t"; │
│ +import { buildGauge, gaugeWidth } from "./G │
│ auge"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +describe("buildGauge", () => { │
│ + it("renders the exact declared width", () │
│ => { │
│ + for (const v of [0, 47, 100]) { │
│ + expect(len(buildGauge(v, { cells: 10 │
│ }))).toBe(gaugeWidth(10)); │
│ + expect(len(buildGauge(v, { cells: 10, │
│ threshold: 70 }))).toBe(gaugeWidth(10, v >= │
│ 70 ? "CRITICAL" : "NOMINAL")); │
│ + } │
│ + }); │
│ + │
│ + it("places the threshold marker inside th │
│ e bar", () => { │
│ + const g = buildGauge(30, { cells: 10, t │
│ hreshold: 70 }); │
│ + expect([...g][8]).toBe("│"); │
│ + }); │
│ + │
│ + it("flips the status word at the threshol │
│ d", () => { │
│ + expect(buildGauge(47, { threshold: 70 } │
│ )).toContain("NOMINAL"); │
│ + expect(buildGauge(85, { threshold: 70 } │
│ )).toContain("CRITICAL"); │
│ + }); │
│ + │
│ + it("omits marker and status without a thr │
│ eshold", () => { │
│ + const g = buildGauge(47, { cells: 10 }) │
│ ; │
│ + expect(g).not.toContain("│"); │
│ + expect(g.trimEnd().endsWith("%")).toBe( │
│ true); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Gauge/Gauge.ts ──────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Gauge/Gauge.ts b/ooknet-design/src/compo │
│ nents/Gauge/Gauge.ts │
│ new file mode 100644 │
│ index 0000000..af96bb2 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Gauge/Gauge.ts │
│ @@ -0,0 +1,38 @@ │
│ +// Progress bar with an optional threshold marker and status word: │
│ +// [▓▓▓▓▓░░│░░] 47% NOMINAL │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +export interface Props { │
│ + /** 0-100. */ │
│ + value: number; │
│ + /** 0-100; draws a │ marker and flips the status word above it. */ │
│ + threshold?: number; │
│ + /** Bar width in cells, excluding brackets and the readout. */ │
│ + cells?: number; │
│ + low?: string; │
│ + high?: string; │
│ +} │
│ + │
│ +const clamp = (v: number) => Math.max(0, Math.min(100, Math.round(v))); │
│ + │
│ +export function buildGauge( │
│ + value: number, │
│ + { cells = 20, threshold, low = "NOMINAL", high = "CRITICAL" }: Omit<Props, "val │
│ ue"> = {}, │
│ +): string { │
│ + const v = clamp(value); │
│ + const filled = Math.round((v / 100) * cells); │
│ + const bar = [..."▓".repeat(filled) + "░".repeat(cells - filled)]; │
│ + │
│ + let status = ""; │
│ + if (threshold != null) { │
│ + const t = Math.min(cells - 1, Math.max(0, Math.round((clamp(threshold) / 100) │
│ * cells))); │
│ + bar[t] = "│"; │
│ + status = " " + (v >= threshold ? high : low); │
│ + } │
│ + │
│ + return `[${bar.join("")}] ${String(v).padStart(3)}%${status}`; │
│ +} │
│ + │
│ +// "[" + cells + "] 100%" + optional " STATUS" │
│ +export const gaugeWidth = (cells = 20, status = "") => │
│ + cells + 7 + (status ? 1 + len(status) : 0); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...src/components/Gauge/Gauge.ts ───┐
│ diff --git a/ooknet-design/src/components/Ga │
│ uge/Gauge.ts b/ooknet-design/src/components/ │
│ Gauge/Gauge.ts │
│ new file mode 100644 │
│ index 0000000..af96bb2 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Gauge/Gau │
│ ge.ts │
│ @@ -0,0 +1,38 @@ │
│ +// Progress bar with an optional threshold │
│ marker and status word: │
│ +// [▓▓▓▓▓░░│░░] 47% NOMINAL │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +export interface Props { │
│ + /** 0-100. */ │
│ + value: number; │
│ + /** 0-100; draws a │ marker and flips the │
│ status word above it. */ │
│ + threshold?: number; │
│ + /** Bar width in cells, excluding bracket │
│ s and the readout. */ │
│ + cells?: number; │
│ + low?: string; │
│ + high?: string; │
│ +} │
│ + │
│ +const clamp = (v: number) => Math.max(0, Ma │
│ th.min(100, Math.round(v))); │
│ + │
│ +export function buildGauge( │
│ + value: number, │
│ + { cells = 20, threshold, low = "NOMINAL", │
│ high = "CRITICAL" }: Omit<Props, "value"> = │
│ {}, │
│ +): string { │
│ + const v = clamp(value); │
│ + const filled = Math.round((v / 100) * cel │
│ ls); │
│ + const bar = [..."▓".repeat(filled) + "░". │
│ repeat(cells - filled)]; │
│ + │
│ + let status = ""; │
│ + if (threshold != null) { │
│ + const t = Math.min(cells - 1, Math.max( │
│ 0, Math.round((clamp(threshold) / 100) * cel │
│ ls))); │
│ + bar[t] = "│"; │
│ + status = " " + (v >= threshold ? high : │
│ low); │
│ + } │
│ + │
│ + return `[${bar.join("")}] ${String(v).pad │
│ Start(3)}%${status}`; │
│ +} │
│ + │
│ +// "[" + cells + "] 100%" + optional " STAT │
│ US" │
│ +export const gaugeWidth = (cells = 20, stat │
│ us = "") => │
│ + cells + 7 + (status ? 1 + len(status) : 0 │
│ ); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Kbd/Kbd.astro ───────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Kbd/Kbd.astro b/ooknet-design/src/compon │
│ ents/Kbd/Kbd.astro │
│ new file mode 100644 │
│ index 0000000..a0e918e │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Kbd/Kbd.astro │
│ @@ -0,0 +1,10 @@ │
│ +--- │
│ +// Inline keyboard chrome: [CTRL]+[K] │
│ +import type { Props } from "./Kbd"; │
│ +import "./Kbd.scss"; │
│ + │
│ +const { keys } = Astro.props as Props; │
│ +--- │
│ +<span class="kbd-combo">{keys.map((k, i) => ( │
│ + <Fragment>{i > 0 ? "+" : ""}<kbd class="kbd">[{k.toUpperCase()}]</kbd></Fragmen │
│ t> │
│ +))}</span> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../src/components/Kbd/Kbd.astro ───┐
│ diff --git a/ooknet-design/src/components/Kb │
│ d/Kbd.astro b/ooknet-design/src/components/K │
│ bd/Kbd.astro │
│ new file mode 100644 │
│ index 0000000..a0e918e │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Kbd/Kbd.a │
│ stro │
│ @@ -0,0 +1,10 @@ │
│ +--- │
│ +// Inline keyboard chrome: [CTRL]+[K] │
│ +import type { Props } from "./Kbd"; │
│ +import "./Kbd.scss"; │
│ + │
│ +const { keys } = Astro.props as Props; │
│ +--- │
│ +<span class="kbd-combo">{keys.map((k, i) => │
│ ( │
│ + <Fragment>{i > 0 ? "+" : ""}<kbd class="k │
│ bd">[{k.toUpperCase()}]</kbd></Fragment> │
│ +))}</span> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Kbd/Kbd.scss ────────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Kbd/Kbd.scss b/ooknet-design/src/compone │
│ nts/Kbd/Kbd.scss │
│ new file mode 100644 │
│ index 0000000..651cd19 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Kbd/Kbd.scss │
│ @@ -0,0 +1,9 @@ │
│ +.kbd-combo { │
│ + white-space: pre; │
│ + │
│ + .kbd { │
│ + font-family: inherit; │
│ + font-size: inherit; │
│ + font-weight: 700; │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...n/src/components/Kbd/Kbd.scss ───┐
│ diff --git a/ooknet-design/src/components/Kb │
│ d/Kbd.scss b/ooknet-design/src/components/Kb │
│ d/Kbd.scss │
│ new file mode 100644 │
│ index 0000000..651cd19 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Kbd/Kbd.s │
│ css │
│ @@ -0,0 +1,9 @@ │
│ +.kbd-combo { │
│ + white-space: pre; │
│ + │
│ + .kbd { │
│ + font-family: inherit; │
│ + font-size: inherit; │
│ + font-weight: 700; │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Kbd/Kbd.ts ──────────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Kbd/Kbd.ts b/ooknet-design/src/component │
│ s/Kbd/Kbd.ts │
│ new file mode 100644 │
│ index 0000000..b5b16ad │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Kbd/Kbd.ts │
│ @@ -0,0 +1,3 @@ │
│ +export interface Props { │
│ + keys: string[]; │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ign/src/components/Kbd/Kbd.ts ───┐
│ diff --git a/ooknet-design/src/components/Kb │
│ d/Kbd.ts b/ooknet-design/src/components/Kbd/ │
│ Kbd.ts │
│ new file mode 100644 │
│ index 0000000..b5b16ad │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Kbd/Kbd.t │
│ s │
│ @@ -0,0 +1,3 @@ │
│ +export interface Props { │
│ + keys: string[]; │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/KeyValue/KeyValue.astro ─────────────────────┐
│ diff --git a/ooknet-design/src/components/KeyValue/KeyValue.astro b/ooknet-design/ │
│ src/components/KeyValue/KeyValue.astro │
│ new file mode 100644 │
│ index 0000000..b55951c │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/KeyValue/KeyValue.astro │
│ @@ -0,0 +1,7 @@ │
│ +--- │
│ +import Frame from "../Frame/Frame.astro"; │
│ +import { buildKeyValue, type Props } from "./KeyValue"; │
│ + │
│ +const { entries, keyW } = Astro.props as Props; │
│ +--- │
│ +{entries.length > 0 && <Frame build={(w) => buildKeyValue(entries, w, keyW)} />} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nents/KeyValue/KeyValue.astro ───┐
│ diff --git a/ooknet-design/src/components/Ke │
│ yValue/KeyValue.astro b/ooknet-design/src/co │
│ mponents/KeyValue/KeyValue.astro │
│ new file mode 100644 │
│ index 0000000..b55951c │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/KeyValue/ │
│ KeyValue.astro │
│ @@ -0,0 +1,7 @@ │
│ +--- │
│ +import Frame from "../Frame/Frame.astro"; │
│ +import { buildKeyValue, type Props } from " │
│ ./KeyValue"; │
│ + │
│ +const { entries, keyW } = Astro.props as Pr │
│ ops; │
│ +--- │
│ +{entries.length > 0 && <Frame build={(w) => │
│ buildKeyValue(entries, w, keyW)} />} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/KeyValue/KeyValue.test.ts ───────────────────┐
│ diff --git a/ooknet-design/src/components/KeyValue/KeyValue.test.ts b/ooknet-desig │
│ n/src/components/KeyValue/KeyValue.test.ts │
│ new file mode 100644 │
│ index 0000000..b0f7214 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/KeyValue/KeyValue.test.ts │
│ @@ -0,0 +1,29 @@ │
│ +import { describe, expect, it } from "vitest"; │
│ +import { buildKeyValue } from "./KeyValue"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +describe("buildKeyValue", () => { │
│ + it("aligns values to the widest key plus gap", () => { │
│ + const out = buildKeyValue([["AUTHOR", "OOKNET"], ["PUBLISHED", "2026-07-16"]] │
│ , 48); │
│ + const lines = out.split("\n"); │
│ + expect(lines[0]).toBe("AUTHOR OOKNET"); │
│ + expect(lines[1]).toBe("PUBLISHED 2026-07-16"); │
│ + }); │
│ + │
│ + it("wraps long values under their own column", () => { │
│ + const out = buildKeyValue([["NOTE", "a value long enough that it must wrap on │
│ narrow frames"]], 32); │
│ + const lines = out.split("\n"); │
│ + expect(lines.length).toBeGreaterThan(1); │
│ + expect(lines[1].startsWith(" ".repeat(7))).toBe(true); │
│ + for (const ln of lines) expect(len(ln)).toBeLessThanOrEqual(32); │
│ + }); │
│ + │
│ + it("respects an explicit key width", () => { │
│ + const out = buildKeyValue([["A", "B"]], 48, 12); │
│ + expect(out).toBe("A B"); │
│ + }); │
│ + │
│ + it("handles empty entries", () => { │
│ + expect(buildKeyValue([], 48)).toBe(""); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nts/KeyValue/KeyValue.test.ts ───┐
│ diff --git a/ooknet-design/src/components/Ke │
│ yValue/KeyValue.test.ts b/ooknet-design/src/ │
│ components/KeyValue/KeyValue.test.ts │
│ new file mode 100644 │
│ index 0000000..b0f7214 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/KeyValue/ │
│ KeyValue.test.ts │
│ @@ -0,0 +1,29 @@ │
│ +import { describe, expect, it } from "vites │
│ t"; │
│ +import { buildKeyValue } from "./KeyValue"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +describe("buildKeyValue", () => { │
│ + it("aligns values to the widest key plus │
│ gap", () => { │
│ + const out = buildKeyValue([["AUTHOR", " │
│ OOKNET"], ["PUBLISHED", "2026-07-16"]], 48); │
│ + const lines = out.split("\n"); │
│ + expect(lines[0]).toBe("AUTHOR OOKN │
│ ET"); │
│ + expect(lines[1]).toBe("PUBLISHED 2026 │
│ -07-16"); │
│ + }); │
│ + │
│ + it("wraps long values under their own col │
│ umn", () => { │
│ + const out = buildKeyValue([["NOTE", "a │
│ value long enough that it must wrap on narro │
│ w frames"]], 32); │
│ + const lines = out.split("\n"); │
│ + expect(lines.length).toBeGreaterThan(1) │
│ ; │
│ + expect(lines[1].startsWith(" ".repeat(7 │
│ ))).toBe(true); │
│ + for (const ln of lines) expect(len(ln)) │
│ .toBeLessThanOrEqual(32); │
│ + }); │
│ + │
│ + it("respects an explicit key width", () = │
│ > { │
│ + const out = buildKeyValue([["A", "B"]], │
│ 48, 12); │
│ + expect(out).toBe("A B"); │
│ + }); │
│ + │
│ + it("handles empty entries", () => { │
│ + expect(buildKeyValue([], 48)).toBe(""); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/KeyValue/KeyValue.ts ────────────────────────┐
│ diff --git a/ooknet-design/src/components/KeyValue/KeyValue.ts b/ooknet-design/src │
│ /components/KeyValue/KeyValue.ts │
│ new file mode 100644 │
│ index 0000000..0628923 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/KeyValue/KeyValue.ts │
│ @@ -0,0 +1,24 @@ │
│ +// Labeled key/value rows with values word-wrapped under their column - │
│ +// the pattern ArticleMeta hand-rolls, as a reusable block. │
│ +import { checkGrid, len, pad, wrap } from "../../lib/ascii"; │
│ + │
│ +export interface Props { │
│ + entries: [string, string][]; │
│ + keyW?: number; │
│ +} │
│ + │
│ +export function buildKeyValue(entries: [string, string][], width: number, keyW?: │
│ number): string { │
│ + if (!entries.length) return ""; │
│ + const kw = keyW ?? Math.max(...entries.map(([k]) => len(k))) + 3; │
│ + const valW = Math.max(1, width - kw); │
│ + │
│ + const lines: string[] = []; │
│ + for (const [key, value] of entries) { │
│ + wrap(value, valW) │
│ + .split("\n") │
│ + .forEach((ln, i) => { │
│ + lines.push(pad(i === 0 ? key : "", kw) + ln); │
│ + }); │
│ + } │
│ + return checkGrid(lines.join("\n"), width, "key-value"); │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...mponents/KeyValue/KeyValue.ts ───┐
│ diff --git a/ooknet-design/src/components/Ke │
│ yValue/KeyValue.ts b/ooknet-design/src/compo │
│ nents/KeyValue/KeyValue.ts │
│ new file mode 100644 │
│ index 0000000..0628923 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/KeyValue/ │
│ KeyValue.ts │
│ @@ -0,0 +1,24 @@ │
│ +// Labeled key/value rows with values word- │
│ wrapped under their column - │
│ +// the pattern ArticleMeta hand-rolls, as a │
│ reusable block. │
│ +import { checkGrid, len, pad, wrap } from " │
│ ../../lib/ascii"; │
│ + │
│ +export interface Props { │
│ + entries: [string, string][]; │
│ + keyW?: number; │
│ +} │
│ + │
│ +export function buildKeyValue(entries: [str │
│ ing, string][], width: number, keyW?: number │
│ ): string { │
│ + if (!entries.length) return ""; │
│ + const kw = keyW ?? Math.max(...entries.ma │
│ p(([k]) => len(k))) + 3; │
│ + const valW = Math.max(1, width - kw); │
│ + │
│ + const lines: string[] = []; │
│ + for (const [key, value] of entries) { │
│ + wrap(value, valW) │
│ + .split("\n") │
│ + .forEach((ln, i) => { │
│ + lines.push(pad(i === 0 ? key : "", │
│ kw) + ln); │
│ + }); │
│ + } │
│ + return checkGrid(lines.join("\n"), width, │
│ "key-value"); │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/NumberStepper/NumberStepper.astro ───────────┐
│ diff --git a/ooknet-design/src/components/NumberStepper/NumberStepper.astro b/ookn │
│ et-design/src/components/NumberStepper/NumberStepper.astro │
│ new file mode 100644 │
│ index 0000000..d8fd1a5 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/NumberStepper/NumberStepper.astro │
│ @@ -0,0 +1,30 @@ │
│ +--- │
│ +// [ - ] 042 [ + ] - native number input between two step buttons. │
│ +import type { Props } from "./NumberStepper"; │
│ +import "./NumberStepper.scss"; │
│ + │
│ +const { name, value = 0, min, max, step = 1, cells = 4 } = Astro.props as Props; │
│ +--- │
│ +<span class="num-stepper"><button type="button" class="step-btn" data-step="down" │
│ aria-label={`Decrease ${name}`}>[ - ]</button>{" "}<input │
│ + class="num-input" │
│ + type="number" │
│ + name={name} │
│ + value={value} │
│ + min={min} │
│ + max={max} │
│ + step={step} │
│ + aria-label={name} │
│ + style={`width: calc(var(--cell-w) * ${cells})`} │
│ +/>{" "}<button type="button" class="step-btn" data-step="up" aria-label={`Increas │
│ e ${name}`}>[ + ]</button></span> │
│ + │
│ +<script> │
│ + document.addEventListener("click", (e) => { │
│ + const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".step-btn") │
│ ; │
│ + if (!btn) return; │
│ + const input = btn.closest(".num-stepper")?.querySelector<HTMLInputElement>(". │
│ num-input"); │
│ + if (!input) return; │
│ + if (btn.dataset.step === "up") input.stepUp(); │
│ + else input.stepDown(); │
│ + input.dispatchEvent(new Event("change", { bubbles: true })); │
│ + }); │
│ +</script> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...erStepper/NumberStepper.astro ───┐
│ diff --git a/ooknet-design/src/components/Nu │
│ mberStepper/NumberStepper.astro b/ooknet-des │
│ ign/src/components/NumberStepper/NumberStepp │
│ er.astro │
│ new file mode 100644 │
│ index 0000000..d8fd1a5 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/NumberSte │
│ pper/NumberStepper.astro │
│ @@ -0,0 +1,30 @@ │
│ +--- │
│ +// [ - ] 042 [ + ] - native number input be │
│ tween two step buttons. │
│ +import type { Props } from "./NumberStepper │
│ "; │
│ +import "./NumberStepper.scss"; │
│ + │
│ +const { name, value = 0, min, max, step = 1 │
│ , cells = 4 } = Astro.props as Props; │
│ +--- │
│ +<span class="num-stepper"><button type="but │
│ ton" class="step-btn" data-step="down" aria- │
│ label={`Decrease ${name}`}>[ - ]</button>{" │
│ "}<input │
│ + class="num-input" │
│ + type="number" │
│ + name={name} │
│ + value={value} │
│ + min={min} │
│ + max={max} │
│ + step={step} │
│ + aria-label={name} │
│ + style={`width: calc(var(--cell-w) * ${cel │
│ ls})`} │
│ +/>{" "}<button type="button" class="step-bt │
│ n" data-step="up" aria-label={`Increase ${na │
│ me}`}>[ + ]</button></span> │
│ + │
│ +<script> │
│ + document.addEventListener("click", (e) => │
│ { │
│ + const btn = (e.target as HTMLElement).c │
│ losest<HTMLButtonElement>(".step-btn"); │
│ + if (!btn) return; │
│ + const input = btn.closest(".num-stepper │
│ ")?.querySelector<HTMLInputElement>(".num-in │
│ put"); │
│ + if (!input) return; │
│ + if (btn.dataset.step === "up") input.st │
│ epUp(); │
│ + else input.stepDown(); │
│ + input.dispatchEvent(new Event("change", │
│ { bubbles: true })); │
│ + }); │
│ +</script> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/NumberStepper/NumberStepper.scss ────────────┐
│ diff --git a/ooknet-design/src/components/NumberStepper/NumberStepper.scss b/ookne │
│ t-design/src/components/NumberStepper/NumberStepper.scss │
│ new file mode 100644 │
│ index 0000000..efa2c3e │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/NumberStepper/NumberStepper.scss │
│ @@ -0,0 +1,48 @@ │
│ +.num-stepper { │
│ + white-space: pre; │
│ + │
│ + .step-btn { │
│ + font: inherit; │
│ + color: var(--ink); │
│ + background: none; │
│ + border: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + cursor: pointer; │
│ + white-space: pre; │
│ + │
│ + &:hover { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + } │
│ + │
│ + .num-input { │
│ + font: inherit; │
│ + color: inherit; │
│ + background: transparent; │
│ + border: 0; │
│ + outline: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + text-align: center; │
│ + -moz-appearance: textfield; │
│ + appearance: textfield; │
│ + │
│ + &::-webkit-outer-spin-button, │
│ + &::-webkit-inner-spin-button { │
│ + -webkit-appearance: none; │
│ + margin: 0; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed currentColor; │
│ + outline-offset: 2px; │
│ + } │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...berStepper/NumberStepper.scss ───┐
│ diff --git a/ooknet-design/src/components/Nu │
│ mberStepper/NumberStepper.scss b/ooknet-desi │
│ gn/src/components/NumberStepper/NumberSteppe │
│ r.scss │
│ new file mode 100644 │
│ index 0000000..efa2c3e │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/NumberSte │
│ pper/NumberStepper.scss │
│ @@ -0,0 +1,48 @@ │
│ +.num-stepper { │
│ + white-space: pre; │
│ + │
│ + .step-btn { │
│ + font: inherit; │
│ + color: var(--ink); │
│ + background: none; │
│ + border: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + cursor: pointer; │
│ + white-space: pre; │
│ + │
│ + &:hover { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + } │
│ + │
│ + .num-input { │
│ + font: inherit; │
│ + color: inherit; │
│ + background: transparent; │
│ + border: 0; │
│ + outline: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + text-align: center; │
│ + -moz-appearance: textfield; │
│ + appearance: textfield; │
│ + │
│ + &::-webkit-outer-spin-button, │
│ + &::-webkit-inner-spin-button { │
│ + -webkit-appearance: none; │
│ + margin: 0; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed currentColor; │
│ + outline-offset: 2px; │
│ + } │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/NumberStepper/NumberStepper.ts ──────────────┐
│ diff --git a/ooknet-design/src/components/NumberStepper/NumberStepper.ts b/ooknet- │
│ design/src/components/NumberStepper/NumberStepper.ts │
│ new file mode 100644 │
│ index 0000000..21e3950 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/NumberStepper/NumberStepper.ts │
│ @@ -0,0 +1,12 @@ │
│ +export interface Props { │
│ + name: string; │
│ + value?: number; │
│ + min?: number; │
│ + max?: number; │
│ + step?: number; │
│ + /** Digit cells for the input. */ │
│ + cells?: number; │
│ +} │
│ + │
│ +// "[ - ] " + cells + " [ + ]" │
│ +export const stepperWidth = (cells = 4) => 12 + cells; │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...umberStepper/NumberStepper.ts ───┐
│ diff --git a/ooknet-design/src/components/Nu │
│ mberStepper/NumberStepper.ts b/ooknet-design │
│ /src/components/NumberStepper/NumberStepper. │
│ ts │
│ new file mode 100644 │
│ index 0000000..21e3950 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/NumberSte │
│ pper/NumberStepper.ts │
│ @@ -0,0 +1,12 @@ │
│ +export interface Props { │
│ + name: string; │
│ + value?: number; │
│ + min?: number; │
│ + max?: number; │
│ + step?: number; │
│ + /** Digit cells for the input. */ │
│ + cells?: number; │
│ +} │
│ + │
│ +// "[ - ] " + cells + " [ + ]" │
│ +export const stepperWidth = (cells = 4) => │
│ 12 + cells; │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Pagination/Pagination.astro ─────────────────┐
│ diff --git a/ooknet-design/src/components/Pagination/Pagination.astro b/ooknet-des │
│ ign/src/components/Pagination/Pagination.astro │
│ new file mode 100644 │
│ index 0000000..c6843e8 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Pagination/Pagination.astro │
│ @@ -0,0 +1,27 @@ │
│ +--- │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import { paginationItems, type Props } from "./Pagination"; │
│ +import "./Pagination.scss"; │
│ + │
│ +const { current, total, href } = Astro.props as Props; │
│ +const items = paginationItems(current, total); │
│ +const prev = current > 1 ? href(current - 1) : null; │
│ +const next = current < total ? href(current + 1) : null; │
│ +--- │
│ +{total > 1 && ( │
│ + <nav class="pager" aria-label="Pagination"> │
│ + <Pre>{prev │
│ + ? <a class="pager-arrow" href={prev} aria-label="Previous page">{"[ < ]"}</ │
│ a> │
│ + : <span class="pager-arrow disabled">{"[ < ]"}</span> │
│ + }{" "}{items.map((it, i) => ( │
│ + <Fragment>{i > 0 ? " " : ""}{ │
│ + it.kind === "gap" ? <span class="pager-gap">...</span> │
│ + : it.current ? <span class="pager-current" aria-current="page">[{it.n}]</ │
│ span> │
│ + : <a class="pager-page" href={href(it.n)}>{it.n}</a> │
│ + }</Fragment> │
│ + ))}{" "}{next │
│ + ? <a class="pager-arrow" href={next} aria-label="Next page">{"[ > ]"}</a> │
│ + : <span class="pager-arrow disabled">{"[ > ]"}</span> │
│ + }</Pre> │
│ + </nav> │
│ +)} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...s/Pagination/Pagination.astro ───┐
│ diff --git a/ooknet-design/src/components/Pa │
│ gination/Pagination.astro b/ooknet-design/sr │
│ c/components/Pagination/Pagination.astro │
│ new file mode 100644 │
│ index 0000000..c6843e8 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Paginatio │
│ n/Pagination.astro │
│ @@ -0,0 +1,27 @@ │
│ +--- │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import { paginationItems, type Props } from │
│ "./Pagination"; │
│ +import "./Pagination.scss"; │
│ + │
│ +const { current, total, href } = Astro.prop │
│ s as Props; │
│ +const items = paginationItems(current, tota │
│ l); │
│ +const prev = current > 1 ? href(current - 1 │
│ ) : null; │
│ +const next = current < total ? href(current │
│ + 1) : null; │
│ +--- │
│ +{total > 1 && ( │
│ + <nav class="pager" aria-label="Pagination │
│ "> │
│ + <Pre>{prev │
│ + ? <a class="pager-arrow" href={prev} │
│ aria-label="Previous page">{"[ < ]"}</a> │
│ + : <span class="pager-arrow disabled"> │
│ {"[ < ]"}</span> │
│ + }{" "}{items.map((it, i) => ( │
│ + <Fragment>{i > 0 ? " " : ""}{ │
│ + it.kind === "gap" ? <span class="pa │
│ ger-gap">...</span> │
│ + : it.current ? <span class="pager-c │
│ urrent" aria-current="page">[{it.n}]</span> │
│ + : <a class="pager-page" href={href( │
│ it.n)}>{it.n}</a> │
│ + }</Fragment> │
│ + ))}{" "}{next │
│ + ? <a class="pager-arrow" href={next} │
│ aria-label="Next page">{"[ > ]"}</a> │
│ + : <span class="pager-arrow disabled"> │
│ {"[ > ]"}</span> │
│ + }</Pre> │
│ + </nav> │
│ +)} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Pagination/Pagination.scss ──────────────────┐
│ diff --git a/ooknet-design/src/components/Pagination/Pagination.scss b/ooknet-desi │
│ gn/src/components/Pagination/Pagination.scss │
│ new file mode 100644 │
│ index 0000000..b0d09e9 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Pagination/Pagination.scss │
│ @@ -0,0 +1,27 @@ │
│ +.pager { │
│ + a { │
│ + color: var(--ink); │
│ + text-decoration: none; │
│ + │
│ + &:hover { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + } │
│ + │
│ + .pager-current { │
│ + font-weight: 700; │
│ + background: var(--ink); │
│ + color: var(--paper); │
│ + } │
│ + │
│ + .pager-gap, │
│ + .pager-arrow.disabled { │
│ + color: var(--ink-soft); │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ts/Pagination/Pagination.scss ───┐
│ diff --git a/ooknet-design/src/components/Pa │
│ gination/Pagination.scss b/ooknet-design/src │
│ /components/Pagination/Pagination.scss │
│ new file mode 100644 │
│ index 0000000..b0d09e9 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Paginatio │
│ n/Pagination.scss │
│ @@ -0,0 +1,27 @@ │
│ +.pager { │
│ + a { │
│ + color: var(--ink); │
│ + text-decoration: none; │
│ + │
│ + &:hover { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + } │
│ + │
│ + .pager-current { │
│ + font-weight: 700; │
│ + background: var(--ink); │
│ + color: var(--paper); │
│ + } │
│ + │
│ + .pager-gap, │
│ + .pager-arrow.disabled { │
│ + color: var(--ink-soft); │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Pagination/Pagination.test.ts ───────────────┐
│ diff --git a/ooknet-design/src/components/Pagination/Pagination.test.ts b/ooknet-d │
│ esign/src/components/Pagination/Pagination.test.ts │
│ new file mode 100644 │
│ index 0000000..bafc20c │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Pagination/Pagination.test.ts │
│ @@ -0,0 +1,28 @@ │
│ +import { describe, expect, it } from "vitest"; │
│ +import { paginationItems, type PageItem } from "./Pagination"; │
│ + │
│ +const render = (items: PageItem[]) => │
│ + items.map((it) => (it.kind === "gap" ? "..." : it.current ? `[${it.n}]` : Strin │
│ g(it.n))).join(" "); │
│ + │
│ +describe("paginationItems", () => { │
│ + it("windows around the current page with edge pages pinned", () => { │
│ + expect(render(paginationItems(6, 20))).toBe("1 ... 4 5 [6] 7 8 ... 20"); │
│ + }); │
│ + │
│ + it("collapses gaps that would hide a single page", () => { │
│ + expect(render(paginationItems(4, 20))).toBe("1 2 3 [4] 5 6 ... 20"); │
│ + }); │
│ + │
│ + it("handles the first and last pages", () => { │
│ + expect(render(paginationItems(1, 20))).toBe("[1] 2 3 4 5 ... 20"); │
│ + expect(render(paginationItems(20, 20))).toBe("1 ... 16 17 18 19 [20]"); │
│ + }); │
│ + │
│ + it("shows every page when total fits the window", () => { │
│ + expect(render(paginationItems(2, 4))).toBe("1 [2] 3 4"); │
│ + }); │
│ + │
│ + it("clamps out-of-range current pages", () => { │
│ + expect(render(paginationItems(99, 3))).toBe("1 2 [3]"); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...Pagination/Pagination.test.ts ───┐
│ diff --git a/ooknet-design/src/components/Pa │
│ gination/Pagination.test.ts b/ooknet-design/ │
│ src/components/Pagination/Pagination.test.ts │
│ new file mode 100644 │
│ index 0000000..bafc20c │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Paginatio │
│ n/Pagination.test.ts │
│ @@ -0,0 +1,28 @@ │
│ +import { describe, expect, it } from "vites │
│ t"; │
│ +import { paginationItems, type PageItem } f │
│ rom "./Pagination"; │
│ + │
│ +const render = (items: PageItem[]) => │
│ + items.map((it) => (it.kind === "gap" ? ". │
│ .." : it.current ? `[${it.n}]` : String(it.n │
│ ))).join(" "); │
│ + │
│ +describe("paginationItems", () => { │
│ + it("windows around the current page with │
│ edge pages pinned", () => { │
│ + expect(render(paginationItems(6, 20))). │
│ toBe("1 ... 4 5 [6] 7 8 ... 20"); │
│ + }); │
│ + │
│ + it("collapses gaps that would hide a sing │
│ le page", () => { │
│ + expect(render(paginationItems(4, 20))). │
│ toBe("1 2 3 [4] 5 6 ... 20"); │
│ + }); │
│ + │
│ + it("handles the first and last pages", () │
│ => { │
│ + expect(render(paginationItems(1, 20))). │
│ toBe("[1] 2 3 4 5 ... 20"); │
│ + expect(render(paginationItems(20, 20))) │
│ .toBe("1 ... 16 17 18 19 [20]"); │
│ + }); │
│ + │
│ + it("shows every page when total fits the │
│ window", () => { │
│ + expect(render(paginationItems(2, 4))).t │
│ oBe("1 [2] 3 4"); │
│ + }); │
│ + │
│ + it("clamps out-of-range current pages", ( │
│ ) => { │
│ + expect(render(paginationItems(99, 3))). │
│ toBe("1 2 [3]"); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Pagination/Pagination.ts ────────────────────┐
│ diff --git a/ooknet-design/src/components/Pagination/Pagination.ts b/ooknet-design │
│ /src/components/Pagination/Pagination.ts │
│ new file mode 100644 │
│ index 0000000..c0cbbaf │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Pagination/Pagination.ts │
│ @@ -0,0 +1,33 @@ │
│ +// Numbered pager: 1 ... 4 5 [6] 7 8 ... 20. First and last pages always │
│ +// show; a sliding window surrounds the current page. │
│ +export interface Props { │
│ + current: number; │
│ + total: number; │
│ + href: (n: number) => string; │
│ +} │
│ + │
│ +export type PageItem = │
│ + | { kind: "page"; n: number; current: boolean } │
│ + | { kind: "gap" }; │
│ + │
│ +export function paginationItems(current: number, total: number, windowSize = 5): │
│ PageItem[] { │
│ + const cur = Math.max(1, Math.min(total, current)); │
│ + if (total < 1) return []; │
│ + │
│ + const half = Math.floor(windowSize / 2); │
│ + let start = Math.max(1, cur - half); │
│ + const end = Math.min(total, start + windowSize - 1); │
│ + start = Math.max(1, end - windowSize + 1); │
│ + │
│ + const items: PageItem[] = []; │
│ + if (start > 1) { │
│ + items.push({ kind: "page", n: 1, current: cur === 1 }); │
│ + if (start > 2) items.push({ kind: "gap" }); │
│ + } │
│ + for (let n = start; n <= end; n++) items.push({ kind: "page", n, current: n === │
│ cur }); │
│ + if (end < total) { │
│ + if (end < total - 1) items.push({ kind: "gap" }); │
│ + items.push({ kind: "page", n: total, current: cur === total }); │
│ + } │
│ + return items; │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ents/Pagination/Pagination.ts ───┐
│ diff --git a/ooknet-design/src/components/Pa │
│ gination/Pagination.ts b/ooknet-design/src/c │
│ omponents/Pagination/Pagination.ts │
│ new file mode 100644 │
│ index 0000000..c0cbbaf │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Paginatio │
│ n/Pagination.ts │
│ @@ -0,0 +1,33 @@ │
│ +// Numbered pager: 1 ... 4 5 [6] 7 8 ... 20 │
│ . First and last pages always │
│ +// show; a sliding window surrounds the cur │
│ rent page. │
│ +export interface Props { │
│ + current: number; │
│ + total: number; │
│ + href: (n: number) => string; │
│ +} │
│ + │
│ +export type PageItem = │
│ + | { kind: "page"; n: number; current: boo │
│ lean } │
│ + | { kind: "gap" }; │
│ + │
│ +export function paginationItems(current: nu │
│ mber, total: number, windowSize = 5): PageIt │
│ em[] { │
│ + const cur = Math.max(1, Math.min(total, c │
│ urrent)); │
│ + if (total < 1) return []; │
│ + │
│ + const half = Math.floor(windowSize / 2); │
│ + let start = Math.max(1, cur - half); │
│ + const end = Math.min(total, start + windo │
│ wSize - 1); │
│ + start = Math.max(1, end - windowSize + 1) │
│ ; │
│ + │
│ + const items: PageItem[] = []; │
│ + if (start > 1) { │
│ + items.push({ kind: "page", n: 1, curren │
│ t: cur === 1 }); │
│ + if (start > 2) items.push({ kind: "gap" │
│ }); │
│ + } │
│ + for (let n = start; n <= end; n++) items. │
│ push({ kind: "page", n, current: n === cur } │
│ ); │
│ + if (end < total) { │
│ + if (end < total - 1) items.push({ kind: │
│ "gap" }); │
│ + items.push({ kind: "page", n: total, cu │
│ rrent: cur === total }); │
│ + } │
│ + return items; │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Sparkline/Sparkline.astro ───────────────────┐
│ diff --git a/ooknet-design/src/components/Sparkline/Sparkline.astro b/ooknet-desig │
│ n/src/components/Sparkline/Sparkline.astro │
│ new file mode 100644 │
│ index 0000000..829c22f │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Sparkline/Sparkline.astro │
│ @@ -0,0 +1,9 @@ │
│ +--- │
│ +import { buildSparkline, type Props } from "./Sparkline"; │
│ + │
│ +const { values, width } = Astro.props as Props; │
│ +const label = values.length │
│ + ? `series of ${values.length} values, latest ${values[values.length - 1]}` │
│ + : "empty series"; │
│ +--- │
│ +<span class="sparkline" role="img" aria-label={label}>{buildSparkline(values, wid │
│ th)}</span> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nts/Sparkline/Sparkline.astro ───┐
│ diff --git a/ooknet-design/src/components/Sp │
│ arkline/Sparkline.astro b/ooknet-design/src/ │
│ components/Sparkline/Sparkline.astro │
│ new file mode 100644 │
│ index 0000000..829c22f │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Sparkline │
│ /Sparkline.astro │
│ @@ -0,0 +1,9 @@ │
│ +--- │
│ +import { buildSparkline, type Props } from │
│ "./Sparkline"; │
│ + │
│ +const { values, width } = Astro.props as Pr │
│ ops; │
│ +const label = values.length │
│ + ? `series of ${values.length} values, lat │
│ est ${values[values.length - 1]}` │
│ + : "empty series"; │
│ +--- │
│ +<span class="sparkline" role="img" aria-lab │
│ el={label}>{buildSparkline(values, width)}</ │
│ span> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Sparkline/Sparkline.test.ts ─────────────────┐
│ diff --git a/ooknet-design/src/components/Sparkline/Sparkline.test.ts b/ooknet-des │
│ ign/src/components/Sparkline/Sparkline.test.ts │
│ new file mode 100644 │
│ index 0000000..7e58dff │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Sparkline/Sparkline.test.ts │
│ @@ -0,0 +1,27 @@ │
│ +import { describe, expect, it } from "vitest"; │
│ +import { buildSparkline } from "./Sparkline"; │
│ + │
│ +describe("buildSparkline", () => { │
│ + it("emits one glyph per value", () => { │
│ + expect([...buildSparkline([1, 2, 3])]).toHaveLength(3); │
│ + }); │
│ + │
│ + it("maps min to the lowest block and max to the highest", () => { │
│ + const s = [...buildSparkline([0, 100])]; │
│ + expect(s[0]).toBe("▁"); │
│ + expect(s[1]).toBe("█"); │
│ + }); │
│ + │
│ + it("renders a flat series at mid height", () => { │
│ + expect(buildSparkline([5, 5, 5])).toBe("▄▄▄"); │
│ + }); │
│ + │
│ + it("bucket-averages long series down to width", () => { │
│ + const s = buildSparkline(Array.from({ length: 100 }, (_, i) => i), 10); │
│ + expect([...s]).toHaveLength(10); │
│ + }); │
│ + │
│ + it("handles empty input", () => { │
│ + expect(buildSparkline([])).toBe(""); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...s/Sparkline/Sparkline.test.ts ───┐
│ diff --git a/ooknet-design/src/components/Sp │
│ arkline/Sparkline.test.ts b/ooknet-design/sr │
│ c/components/Sparkline/Sparkline.test.ts │
│ new file mode 100644 │
│ index 0000000..7e58dff │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Sparkline │
│ /Sparkline.test.ts │
│ @@ -0,0 +1,27 @@ │
│ +import { describe, expect, it } from "vites │
│ t"; │
│ +import { buildSparkline } from "./Sparkline │
│ "; │
│ + │
│ +describe("buildSparkline", () => { │
│ + it("emits one glyph per value", () => { │
│ + expect([...buildSparkline([1, 2, 3])]). │
│ toHaveLength(3); │
│ + }); │
│ + │
│ + it("maps min to the lowest block and max │
│ to the highest", () => { │
│ + const s = [...buildSparkline([0, 100])] │
│ ; │
│ + expect(s[0]).toBe("▁"); │
│ + expect(s[1]).toBe("█"); │
│ + }); │
│ + │
│ + it("renders a flat series at mid height", │
│ () => { │
│ + expect(buildSparkline([5, 5, 5])).toBe( │
│ "▄▄▄"); │
│ + }); │
│ + │
│ + it("bucket-averages long series down to w │
│ idth", () => { │
│ + const s = buildSparkline(Array.from({ l │
│ ength: 100 }, (_, i) => i), 10); │
│ + expect([...s]).toHaveLength(10); │
│ + }); │
│ + │
│ + it("handles empty input", () => { │
│ + expect(buildSparkline([])).toBe(""); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Sparkline/Sparkline.ts ──────────────────────┐
│ diff --git a/ooknet-design/src/components/Sparkline/Sparkline.ts b/ooknet-design/s │
│ rc/components/Sparkline/Sparkline.ts │
│ new file mode 100644 │
│ index 0000000..d01b807 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Sparkline/Sparkline.ts │
│ @@ -0,0 +1,26 @@ │
│ +// Inline series as block glyphs: ▁▂▄▇▅▂ - all within ON_GRID. │
│ +export interface Props { │
│ + values: number[]; │
│ + /** Max glyphs; longer series bucket-average down. */ │
│ + width?: number; │
│ +} │
│ + │
│ +const BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; │
│ + │
│ +export function buildSparkline(values: number[], width = values.length): string { │
│ + if (!values.length || width < 1) return ""; │
│ + │
│ + const n = Math.min(width, values.length); │
│ + const buckets = Array.from({ length: n }, (_, i) => { │
│ + const start = Math.floor((i * values.length) / n); │
│ + const end = Math.max(start + 1, Math.floor(((i + 1) * values.length) / n)); │
│ + const slice = values.slice(start, end); │
│ + return slice.reduce((a, b) => a + b, 0) / slice.length; │
│ + }); │
│ + │
│ + const min = Math.min(...buckets); │
│ + const span = Math.max(...buckets) - min; │
│ + return buckets │
│ + .map((v) => BLOCKS[span === 0 ? 3 : Math.min(7, Math.floor(((v - min) / span) │
│ * 8))]) │
│ + .join(""); │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...onents/Sparkline/Sparkline.ts ───┐
│ diff --git a/ooknet-design/src/components/Sp │
│ arkline/Sparkline.ts b/ooknet-design/src/com │
│ ponents/Sparkline/Sparkline.ts │
│ new file mode 100644 │
│ index 0000000..d01b807 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Sparkline │
│ /Sparkline.ts │
│ @@ -0,0 +1,26 @@ │
│ +// Inline series as block glyphs: ▁▂▄▇▅▂ - │
│ all within ON_GRID. │
│ +export interface Props { │
│ + values: number[]; │
│ + /** Max glyphs; longer series bucket-aver │
│ age down. */ │
│ + width?: number; │
│ +} │
│ + │
│ +const BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆ │
│ ", "▇", "█"]; │
│ + │
│ +export function buildSparkline(values: numb │
│ er[], width = values.length): string { │
│ + if (!values.length || width < 1) return " │
│ "; │
│ + │
│ + const n = Math.min(width, values.length); │
│ + const buckets = Array.from({ length: n }, │
│ (_, i) => { │
│ + const start = Math.floor((i * values.le │
│ ngth) / n); │
│ + const end = Math.max(start + 1, Math.fl │
│ oor(((i + 1) * values.length) / n)); │
│ + const slice = values.slice(start, end); │
│ + return slice.reduce((a, b) => a + b, 0) │
│ / slice.length; │
│ + }); │
│ + │
│ + const min = Math.min(...buckets); │
│ + const span = Math.max(...buckets) - min; │
│ + return buckets │
│ + .map((v) => BLOCKS[span === 0 ? 3 : Mat │
│ h.min(7, Math.floor(((v - min) / span) * 8)) │
│ ]) │
│ + .join(""); │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Steps/Steps.astro ───────────────────────────┐
│ diff --git a/ooknet-design/src/components/Steps/Steps.astro b/ooknet-design/src/co │
│ mponents/Steps/Steps.astro │
│ new file mode 100644 │
│ index 0000000..ec68def │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Steps/Steps.astro │
│ @@ -0,0 +1,7 @@ │
│ +--- │
│ +import Frame from "../Frame/Frame.astro"; │
│ +import { buildSteps, type Props } from "./Steps"; │
│ + │
│ +const { steps, current } = Astro.props as Props; │
│ +--- │
│ +<Frame build={(w) => buildSteps(steps, current, w)} /> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../components/Steps/Steps.astro ───┐
│ diff --git a/ooknet-design/src/components/St │
│ eps/Steps.astro b/ooknet-design/src/componen │
│ ts/Steps/Steps.astro │
│ new file mode 100644 │
│ index 0000000..ec68def │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Steps/Ste │
│ ps.astro │
│ @@ -0,0 +1,7 @@ │
│ +--- │
│ +import Frame from "../Frame/Frame.astro"; │
│ +import { buildSteps, type Props } from "./S │
│ teps"; │
│ + │
│ +const { steps, current } = Astro.props as P │
│ rops; │
│ +--- │
│ +<Frame build={(w) => buildSteps(steps, curr │
│ ent, w)} /> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Steps/Steps.test.ts ─────────────────────────┐
│ diff --git a/ooknet-design/src/components/Steps/Steps.test.ts b/ooknet-design/src/ │
│ components/Steps/Steps.test.ts │
│ new file mode 100644 │
│ index 0000000..e57b1e0 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Steps/Steps.test.ts │
│ @@ -0,0 +1,24 @@ │
│ +import { describe, expect, it } from "vitest"; │
│ +import { buildSteps } from "./Steps"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +const STEPS = ["FETCH", "PARSE", "RENDER"]; │
│ + │
│ +describe("buildSteps", () => { │
│ + it("marks done, current, and pending steps", () => { │
│ + const out = buildSteps(STEPS, 1, 86); │
│ + expect(out).toBe("[x] FETCH ── [?] PARSE ── [ ] RENDER"); │
│ + }); │
│ + │
│ + it("stays horizontal when it fits", () => { │
│ + expect(buildSteps(STEPS, 0, 86)).not.toContain("\n"); │
│ + }); │
│ + │
│ + it("stacks on a vertical spine when too wide", () => { │
│ + const out = buildSteps(["A LONG STEP NAME", "ANOTHER LONG STEP", "A THIRD STE │
│ P"], 1, 30); │
│ + const lines = out.split("\n"); │
│ + expect(lines.length).toBe(5); │
│ + expect(lines[1]).toBe(" │"); │
│ + for (const ln of lines) expect(len(ln)).toBeLessThanOrEqual(30); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...omponents/Steps/Steps.test.ts ───┐
│ diff --git a/ooknet-design/src/components/St │
│ eps/Steps.test.ts b/ooknet-design/src/compon │
│ ents/Steps/Steps.test.ts │
│ new file mode 100644 │
│ index 0000000..e57b1e0 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Steps/Ste │
│ ps.test.ts │
│ @@ -0,0 +1,24 @@ │
│ +import { describe, expect, it } from "vites │
│ t"; │
│ +import { buildSteps } from "./Steps"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +const STEPS = ["FETCH", "PARSE", "RENDER"]; │
│ + │
│ +describe("buildSteps", () => { │
│ + it("marks done, current, and pending step │
│ s", () => { │
│ + const out = buildSteps(STEPS, 1, 86); │
│ + expect(out).toBe("[x] FETCH ── [?] PARS │
│ E ── [ ] RENDER"); │
│ + }); │
│ + │
│ + it("stays horizontal when it fits", () => │
│ { │
│ + expect(buildSteps(STEPS, 0, 86)).not.to │
│ Contain("\n"); │
│ + }); │
│ + │
│ + it("stacks on a vertical spine when too w │
│ ide", () => { │
│ + const out = buildSteps(["A LONG STEP NA │
│ ME", "ANOTHER LONG STEP", "A THIRD STEP"], 1 │
│ , 30); │
│ + const lines = out.split("\n"); │
│ + expect(lines.length).toBe(5); │
│ + expect(lines[1]).toBe(" │"); │
│ + for (const ln of lines) expect(len(ln)) │
│ .toBeLessThanOrEqual(30); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Steps/Steps.ts ──────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Steps/Steps.ts b/ooknet-design/src/compo │
│ nents/Steps/Steps.ts │
│ new file mode 100644 │
│ index 0000000..ddde1e8 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Steps/Steps.ts │
│ @@ -0,0 +1,26 @@ │
│ +// Process steps: horizontal chain when it fits the frame, vertical │
│ +// spine otherwise. [x] done, [?] current, [ ] pending. │
│ +import { G, checkGrid, len } from "../../lib/ascii"; │
│ + │
│ +export interface Props { │
│ + steps: string[]; │
│ + /** 0-based index of the current step. */ │
│ + current: number; │
│ +} │
│ + │
│ +const mark = (i: number, current: number) => │
│ + i < current ? "[x]" : i === current ? "[?]" : "[ ]"; │
│ + │
│ +export function buildSteps(steps: string[], current: number, width: number): stri │
│ ng { │
│ + const parts = steps.map((s, i) => `${mark(i, current)} ${s}`); │
│ + │
│ + const horizontal = parts.join(` ${G.H}${G.H} `); │
│ + if (len(horizontal) <= width) return checkGrid(horizontal, width, "steps"); │
│ + │
│ + const lines: string[] = []; │
│ + parts.forEach((p, i) => { │
│ + lines.push(p); │
│ + if (i < parts.length - 1) lines.push(` ${G.V}`); │
│ + }); │
│ + return checkGrid(lines.join("\n"), width, "steps"); │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...src/components/Steps/Steps.ts ───┐
│ diff --git a/ooknet-design/src/components/St │
│ eps/Steps.ts b/ooknet-design/src/components/ │
│ Steps/Steps.ts │
│ new file mode 100644 │
│ index 0000000..ddde1e8 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Steps/Ste │
│ ps.ts │
│ @@ -0,0 +1,26 @@ │
│ +// Process steps: horizontal chain when it │
│ fits the frame, vertical │
│ +// spine otherwise. [x] done, [?] current, │
│ [ ] pending. │
│ +import { G, checkGrid, len } from "../../li │
│ b/ascii"; │
│ + │
│ +export interface Props { │
│ + steps: string[]; │
│ + /** 0-based index of the current step. */ │
│ + current: number; │
│ +} │
│ + │
│ +const mark = (i: number, current: number) = │
│ > │
│ + i < current ? "[x]" : i === current ? "[? │
│ ]" : "[ ]"; │
│ + │
│ +export function buildSteps(steps: string[], │
│ current: number, width: number): string { │
│ + const parts = steps.map((s, i) => `${mark │
│ (i, current)} ${s}`); │
│ + │
│ + const horizontal = parts.join(` ${G.H}${G │
│ .H} `); │
│ + if (len(horizontal) <= width) return chec │
│ kGrid(horizontal, width, "steps"); │
│ + │
│ + const lines: string[] = []; │
│ + parts.forEach((p, i) => { │
│ + lines.push(p); │
│ + if (i < parts.length - 1) lines.push(` │
│ ${G.V}`); │
│ + }); │
│ + return checkGrid(lines.join("\n"), width, │
│ "steps"); │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Switch/Switch.astro ─────────────────────────┐
│ diff --git a/ooknet-design/src/components/Switch/Switch.astro b/ooknet-design/src/ │
│ components/Switch/Switch.astro │
│ new file mode 100644 │
│ index 0000000..8e1e482 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Switch/Switch.astro │
│ @@ -0,0 +1,9 @@ │
│ +--- │
│ +// Two-state toggle on a checkbox, following the Checkbox pattern: the │
│ +// native input is visually removed, the mark renders 7 stable cells. │
│ +import type { Props } from "./Switch"; │
│ +import "./Switch.scss"; │
│ + │
│ +const { label, name, checked = false, disabled = false } = Astro.props as Props; │
│ +--- │
│ +<label class="switch"><input type="checkbox" role="switch" name={name} checked={c │
│ hecked} disabled={disabled} /><span class="switch-mark" aria-hidden="true"></span> │
│ {" " + label}</label> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...omponents/Switch/Switch.astro ───┐
│ diff --git a/ooknet-design/src/components/Sw │
│ itch/Switch.astro b/ooknet-design/src/compon │
│ ents/Switch/Switch.astro │
│ new file mode 100644 │
│ index 0000000..8e1e482 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Switch/Sw │
│ itch.astro │
│ @@ -0,0 +1,9 @@ │
│ +--- │
│ +// Two-state toggle on a checkbox, followin │
│ g the Checkbox pattern: the │
│ +// native input is visually removed, the ma │
│ rk renders 7 stable cells. │
│ +import type { Props } from "./Switch"; │
│ +import "./Switch.scss"; │
│ + │
│ +const { label, name, checked = false, disab │
│ led = false } = Astro.props as Props; │
│ +--- │
│ +<label class="switch"><input type="checkbox │
│ " role="switch" name={name} checked={checked │
│ } disabled={disabled} /><span class="switch- │
│ mark" aria-hidden="true"></span>{" " + label │
│ }</label> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Switch/Switch.scss ──────────────────────────┐
│ diff --git a/ooknet-design/src/components/Switch/Switch.scss b/ooknet-design/src/c │
│ omponents/Switch/Switch.scss │
│ new file mode 100644 │
│ index 0000000..8a14b8e │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Switch/Switch.scss │
│ @@ -0,0 +1,40 @@ │
│ +.switch { │
│ + cursor: pointer; │
│ + white-space: pre; │
│ + │
│ + input { │
│ + position: absolute; │
│ + opacity: 0; │
│ + width: 1px; │
│ + height: 1px; │
│ + margin: 0; │
│ + pointer-events: none; │
│ + } │
│ + │
│ + .switch-mark::before { │
│ + content: "[ off ]"; │
│ + color: var(--ink-soft); │
│ + } │
│ + input:checked + .switch-mark::before { │
│ + content: "[ ON ]"; │
│ + color: var(--paper); │
│ + background: var(--ink); │
│ + font-weight: 700; │
│ + } │
│ + │
│ + input:focus-visible + .switch-mark { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + │
│ + &:hover:not(:has(input:disabled)) { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:has(input:disabled) { │
│ + color: var(--ink-soft); │
│ + opacity: 0.55; │
│ + cursor: not-allowed; │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...components/Switch/Switch.scss ───┐
│ diff --git a/ooknet-design/src/components/Sw │
│ itch/Switch.scss b/ooknet-design/src/compone │
│ nts/Switch/Switch.scss │
│ new file mode 100644 │
│ index 0000000..8a14b8e │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Switch/Sw │
│ itch.scss │
│ @@ -0,0 +1,40 @@ │
│ +.switch { │
│ + cursor: pointer; │
│ + white-space: pre; │
│ + │
│ + input { │
│ + position: absolute; │
│ + opacity: 0; │
│ + width: 1px; │
│ + height: 1px; │
│ + margin: 0; │
│ + pointer-events: none; │
│ + } │
│ + │
│ + .switch-mark::before { │
│ + content: "[ off ]"; │
│ + color: var(--ink-soft); │
│ + } │
│ + input:checked + .switch-mark::before { │
│ + content: "[ ON ]"; │
│ + color: var(--paper); │
│ + background: var(--ink); │
│ + font-weight: 700; │
│ + } │
│ + │
│ + input:focus-visible + .switch-mark { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + │
│ + &:hover:not(:has(input:disabled)) { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:has(input:disabled) { │
│ + color: var(--ink-soft); │
│ + opacity: 0.55; │
│ + cursor: not-allowed; │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Switch/Switch.ts ────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Switch/Switch.ts b/ooknet-design/src/com │
│ ponents/Switch/Switch.ts │
│ new file mode 100644 │
│ index 0000000..0917a6a │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Switch/Switch.ts │
│ @@ -0,0 +1,11 @@ │
│ +export interface Props { │
│ + label: string; │
│ + name: string; │
│ + checked?: boolean; │
│ + disabled?: boolean; │
│ +} │
│ + │
│ +// "[ off ]" / "[ ON ]" - both 7 cells. │
│ +export const SWITCH_W = 7; │
│ + │
│ +export const switchWidth = (label: string) => SWITCH_W + 1 + [...label].length; │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/components/Switch/Switch.ts ───┐
│ diff --git a/ooknet-design/src/components/Sw │
│ itch/Switch.ts b/ooknet-design/src/component │
│ s/Switch/Switch.ts │
│ new file mode 100644 │
│ index 0000000..0917a6a │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Switch/Sw │
│ itch.ts │
│ @@ -0,0 +1,11 @@ │
│ +export interface Props { │
│ + label: string; │
│ + name: string; │
│ + checked?: boolean; │
│ + disabled?: boolean; │
│ +} │
│ + │
│ +// "[ off ]" / "[ ON ]" - both 7 cells. │
│ +export const SWITCH_W = 7; │
│ + │
│ +export const switchWidth = (label: string) │
│ => SWITCH_W + 1 + [...label].length; │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/pages/components.astro ─────────────────────────────────┐
│ diff --git a/ooknet-design/src/pages/components.astro b/ooknet-design/src/pages/co │
│ mponents.astro │
│ index f2d83e2..48cc70d 100644 │
│ --- a/ooknet-design/src/pages/components.astro │
│ +++ b/ooknet-design/src/pages/components.astro │
│ @@ -21,6 +21,17 @@ import Tabs from "../components/Tabs/Tabs.astro"; │
│ import Breadcrumbs from "../components/Breadcrumbs/Breadcrumbs.astro"; │
│ import Timeline from "../components/Timeline/Timeline.astro"; │
│ import type { TimelineEntry } from "../components/Timeline/Timeline"; │
│ +import Kbd from "../components/Kbd/Kbd.astro"; │
│ +import Gauge from "../components/Gauge/Gauge.astro"; │
│ +import Sparkline from "../components/Sparkline/Sparkline.astro"; │
│ +import KeyValue from "../components/KeyValue/KeyValue.astro"; │
│ +import Banner from "../components/Banner/Banner.astro"; │
│ +import Steps from "../components/Steps/Steps.astro"; │
│ +import Calendar from "../components/Calendar/Calendar.astro"; │
│ +import Pagination from "../components/Pagination/Pagination.astro"; │
│ +import Switch from "../components/Switch/Switch.astro"; │
│ +import NumberStepper from "../components/NumberStepper/NumberStepper.astro"; │
│ +import Dialog from "../components/Dialog/Dialog.astro"; │
│ import { rule, ruleD, row2, pad } from "../lib/ascii"; │
│ │
│ const section = (title: string, right: string) => (w: number) => │
│ @@ -158,6 +169,55 @@ const FILED_TREE: TreeNode[] = [ │
│ <Timeline entries={HISTORY} /> │
│ <Pre>{" "}</Pre> │
│ │
│ + <Frame build={section("GAUGE", "Gauge.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{pad("SIGNAL", LABEL_W)}<Gauge value={47} threshold={70} /></Pre> │
│ + <Pre>{pad("BUFFER", LABEL_W)}<Gauge value={85} threshold={70} /></Pre> │
│ + <Pre>{pad("DECAY", LABEL_W)}<Gauge value={8} /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("SPARKLINE", "Sparkline.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{pad("UPLINK", LABEL_W)}<Sparkline values={[3, 5, 9, 4, 6, 12, 8, 7, 11, 1 │
│ 4, 9, 6]} />{" 14 PEAK"}</Pre> │
│ + <Pre>{pad("NOISE", LABEL_W)}<Sparkline values={[2, 2, 3, 2, 2, 2, 4, 3, 2, 2, 3 │
│ , 2]} />{" 4 PEAK"}</Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("KEY VALUE", "KeyValue.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <KeyValue entries={[ │
│ + ["STATION", "OOKNET-9"], │
│ + ["FREQUENCY", "14.230 MHZ"], │
│ + ["REMARKS", "relay via the northern repeater when the direct path fades below │
│ threshold"], │
│ + ]} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("BANNER", "Banner.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Banner variant="notice" message="scheduled maintenance window sunday 02:00-04: │
│ 00 UTC" /> │
│ + <Banner variant="warning" message="frequency drift detected on channel bravo - │
│ recalibrate before transmitting" /> │
│ + <Banner variant="error" message="uplink lost" /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("STEPS", "Steps.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Steps steps={["DRAFT", "PEER CHECK", "PUBLISH"]} current={1} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("CALENDAR", "Calendar.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Calendar year={2026} month={7} marks={[16, 17]} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("PAGINATION", "Pagination.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pagination current={6} total={20} href={(n) => `/?page=${n}`} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("KBD", "Kbd.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{"press "}<Kbd keys={["/"]} />{" to search, "}<Kbd keys={["CTRL", "K"]} /> │
│ {" for the palette"}</Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ <Frame build={section("TABS", "Tabs.astro")} /> │
│ <Pre>{" "}</Pre> │
│ <Tabs labels={["NIX", "NPM", "MANUAL"]}> │
│ @@ -180,6 +240,28 @@ const FILED_TREE: TreeNode[] = [ │
│ <Breadcrumbs filed="/INFO/GRAPH/ENTROPY/BOUND" /> │
│ <Pre>{" "}</Pre> │
│ │
│ + <Frame build={section("SWITCH", "Switch.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre><Switch label="AUTO-ACK INCOMING" name="demo-autoack" checked /></Pre> │
│ + <Pre><Switch label="BEACON MODE" name="demo-beacon" /></Pre> │
│ + <Pre><Switch label="DISABLED RELAY" name="demo-relay" disabled /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("NUMBER STEPPER", "NumberStepper.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{pad("POWER (W)", LABEL_W)}<NumberStepper name="demo-power" value={25} min │
│ ={0} max={100} step={5} /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("DIALOG", "Dialog.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre><span data-dialog-open="demo-dialog"><Button label="OPEN TRANSMISSION LOG" │
│ variant="primary" /></span></Pre> │
│ + <Dialog id="demo-dialog" title="TRANSMISSION LOG"> │
│ + <Pre>{"2026-07-17 09:41 N0 ACK RECEIVED"}</Pre> │
│ + <Pre>{"2026-07-17 09:38 BEACON SENT ON BRAVO"}</Pre> │
│ + <Pre>{"2026-07-17 09:12 CHANNEL SCAN COMPLETE"}</Pre> │
│ + </Dialog> │
│ + <Pre>{" "}</Pre> │
│ + │
│ <Frame build={section("TOAST", "Toast.astro")} /> │
│ <Pre>{" "}</Pre> │
│ <Pre><span id="toast-ok"><Button label="QUEUE" variant="primary" /></span>{" " │
│ }<span id="toast-error"><Button label="JAM" /></span>{" "}<span id="toast-pending │
│ "><Button label="HOLD" /></span></Pre> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...gn/src/pages/components.astro ───┐
│ diff --git a/ooknet-design/src/pages/compone │
│ nts.astro b/ooknet-design/src/pages/componen │
│ ts.astro │
│ index f2d83e2..48cc70d 100644 │
│ --- a/ooknet-design/src/pages/components.ast │
│ ro │
│ +++ b/ooknet-design/src/pages/components.ast │
│ ro │
│ @@ -21,6 +21,17 @@ import Tabs from "../comp │
│ onents/Tabs/Tabs.astro"; │
│ import Breadcrumbs from "../components/Brea │
│ dcrumbs/Breadcrumbs.astro"; │
│ import Timeline from "../components/Timelin │
│ e/Timeline.astro"; │
│ import type { TimelineEntry } from "../comp │
│ onents/Timeline/Timeline"; │
│ +import Kbd from "../components/Kbd/Kbd.astr │
│ o"; │
│ +import Gauge from "../components/Gauge/Gaug │
│ e.astro"; │
│ +import Sparkline from "../components/Sparkl │
│ ine/Sparkline.astro"; │
│ +import KeyValue from "../components/KeyValu │
│ e/KeyValue.astro"; │
│ +import Banner from "../components/Banner/Ba │
│ nner.astro"; │
│ +import Steps from "../components/Steps/Step │
│ s.astro"; │
│ +import Calendar from "../components/Calenda │
│ r/Calendar.astro"; │
│ +import Pagination from "../components/Pagin │
│ ation/Pagination.astro"; │
│ +import Switch from "../components/Switch/Sw │
│ itch.astro"; │
│ +import NumberStepper from "../components/Nu │
│ mberStepper/NumberStepper.astro"; │
│ +import Dialog from "../components/Dialog/Di │
│ alog.astro"; │
│ import { rule, ruleD, row2, pad } from "../ │
│ lib/ascii"; │
│ │
│ const section = (title: string, right: stri │
│ ng) => (w: number) => │
│ @@ -158,6 +169,55 @@ const FILED_TREE: TreeN │
│ ode[] = [ │
│ <Timeline entries={HISTORY} /> │
│ <Pre>{" "}</Pre> │
│ │
│ + <Frame build={section("GAUGE", "Gauge.ast │
│ ro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{pad("SIGNAL", LABEL_W)}<Gauge value │
│ ={47} threshold={70} /></Pre> │
│ + <Pre>{pad("BUFFER", LABEL_W)}<Gauge value │
│ ={85} threshold={70} /></Pre> │
│ + <Pre>{pad("DECAY", LABEL_W)}<Gauge value= │
│ {8} /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("SPARKLINE", "Spark │
│ line.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{pad("UPLINK", LABEL_W)}<Sparkline v │
│ alues={[3, 5, 9, 4, 6, 12, 8, 7, 11, 14, 9, │
│ 6]} />{" 14 PEAK"}</Pre> │
│ + <Pre>{pad("NOISE", LABEL_W)}<Sparkline va │
│ lues={[2, 2, 3, 2, 2, 2, 4, 3, 2, 2, 3, 2]} │
│ />{" 4 PEAK"}</Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("KEY VALUE", "KeyVa │
│ lue.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <KeyValue entries={[ │
│ + ["STATION", "OOKNET-9"], │
│ + ["FREQUENCY", "14.230 MHZ"], │
│ + ["REMARKS", "relay via the northern rep │
│ eater when the direct path fades below thres │
│ hold"], │
│ + ]} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("BANNER", "Banner.a │
│ stro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Banner variant="notice" message="schedul │
│ ed maintenance window sunday 02:00-04:00 UTC │
│ " /> │
│ + <Banner variant="warning" message="freque │
│ ncy drift detected on channel bravo - recali │
│ brate before transmitting" /> │
│ + <Banner variant="error" message="uplink l │
│ ost" /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("STEPS", "Steps.ast │
│ ro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Steps steps={["DRAFT", "PEER CHECK", "PU │
│ BLISH"]} current={1} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("CALENDAR", "Calend │
│ ar.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Calendar year={2026} month={7} marks={[1 │
│ 6, 17]} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("PAGINATION", "Pagi │
│ nation.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pagination current={6} total={20} href={ │
│ (n) => `/?page=${n}`} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("KBD", "Kbd.astro") │
│ } /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{"press "}<Kbd keys={["/"]} />{" to │
│ search, "}<Kbd keys={["CTRL", "K"]} />{" for │
│ the palette"}</Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ <Frame build={section("TABS", "Tabs.astro │
│ ")} /> │
│ <Pre>{" "}</Pre> │
│ <Tabs labels={["NIX", "NPM", "MANUAL"]}> │
│ @@ -180,6 +240,28 @@ const FILED_TREE: TreeN │
│ ode[] = [ │
│ <Breadcrumbs filed="/INFO/GRAPH/ENTROPY/B │
│ OUND" /> │
│ <Pre>{" "}</Pre> │
│ │
│ + <Frame build={section("SWITCH", "Switch.a │
│ stro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre><Switch label="AUTO-ACK INCOMING" na │
│ me="demo-autoack" checked /></Pre> │
│ + <Pre><Switch label="BEACON MODE" name="de │
│ mo-beacon" /></Pre> │
│ + <Pre><Switch label="DISABLED RELAY" name= │
│ "demo-relay" disabled /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("NUMBER STEPPER", " │
│ NumberStepper.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{pad("POWER (W)", LABEL_W)}<NumberSt │
│ epper name="demo-power" value={25} min={0} m │
│ ax={100} step={5} /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("DIALOG", "Dialog.a │
│ stro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre><span data-dialog-open="demo-dialog" │
│ ><Button label="OPEN TRANSMISSION LOG" varia │
│ nt="primary" /></span></Pre> │
│ + <Dialog id="demo-dialog" title="TRANSMISS │
│ ION LOG"> │
│ + <Pre>{"2026-07-17 09:41 N0 ACK RECEIVE │
│ D"}</Pre> │
│ + <Pre>{"2026-07-17 09:38 BEACON SENT ON │
│ BRAVO"}</Pre> │
│ + <Pre>{"2026-07-17 09:12 CHANNEL SCAN C │
│ OMPLETE"}</Pre> │
│ + </Dialog> │
│ + <Pre>{" "}</Pre> │
│ + │
│ <Frame build={section("TOAST", "Toast.ast │
│ ro")} /> │
│ <Pre>{" "}</Pre> │
│ <Pre><span id="toast-ok"><Button label="Q │
│ UEUE" variant="primary" /></span>{" "}<span │
│ id="toast-error"><Button label="JAM" /></sp │
│ an>{" "}<span id="toast-pending"><Button la │
│ bel="HOLD" /></span></Pre> │
└──────────────────────────────────────────────┘
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET