HASH a04c936afe8e
DATE 2026-07-17
SUBJECT web: menu, tooltip, slider, skeleton, diff component, print styles, and
status page
FILES 22 CHANGED HASH a04c936afe8e
DATE 2026-07-17
SUBJECT web: menu, tooltip, slider, skeleton,
diff component, print styles, and
status page
FILES 22 CHANGED
┌─ ooknet-design/src/components/ArticlePagination/ArticlePagination.astro ───┐
│ diff --git a/ooknet-design/src/components/ArticlePagination/ArticlePagination.astr │
│ o b/ooknet-design/src/components/ArticlePagination/ArticlePagination.astro │
│ index 78e78ce..b9e557d 100644 │
│ --- a/ooknet-design/src/components/ArticlePagination/ArticlePagination.astro │
│ +++ b/ooknet-design/src/components/ArticlePagination/ArticlePagination.astro │
│ @@ -15,8 +15,8 @@ const gap = (w: number) => │
│ Math.max(1, w - (prevLabel ? buttonWidth(prevLabel) : 0) - (nextLabel ? buttonW │
│ idth(nextLabel) : 0)); │
│ --- │
│ {(prev || next) && ( │
│ - <Fragment> │
│ + <nav class="article-pagination" aria-label="Adjacent notes"> │
│ <Pre class="frame-wide">{prevLabel && prev && <Button label={prevLabel} href= │
│ {`/notes/${prev.id}/`} />}{" ".repeat(gap(FRAME_W))}{nextLabel && next && <Button │
│ label={nextLabel} href={`/notes/${next.id}/`} />}</Pre> │
│ <Pre class="frame-narrow">{prevLabel && prev && <Button label={prevLabel} hre │
│ f={`/notes/${prev.id}/`} />}{" ".repeat(gap(MOBILE_FRAME_W))}{nextLabel && next && │
│ <Button label={nextLabel} href={`/notes/${next.id}/`} />}</Pre> │
│ - </Fragment> │
│ + </nav> │
│ )} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ation/ArticlePagination.astro ───┐
│ diff --git a/ooknet-design/src/components/Ar │
│ ticlePagination/ArticlePagination.astro b/oo │
│ knet-design/src/components/ArticlePagination │
│ /ArticlePagination.astro │
│ index 78e78ce..b9e557d 100644 │
│ --- a/ooknet-design/src/components/ArticlePa │
│ gination/ArticlePagination.astro │
│ +++ b/ooknet-design/src/components/ArticlePa │
│ gination/ArticlePagination.astro │
│ @@ -15,8 +15,8 @@ const gap = (w: number) => │
│ Math.max(1, w - (prevLabel ? buttonWidth( │
│ prevLabel) : 0) - (nextLabel ? buttonWidth(n │
│ extLabel) : 0)); │
│ --- │
│ {(prev || next) && ( │
│ - <Fragment> │
│ + <nav class="article-pagination" aria-labe │
│ l="Adjacent notes"> │
│ <Pre class="frame-wide">{prevLabel && p │
│ rev && <Button label={prevLabel} href={`/not │
│ es/${prev.id}/`} />}{" ".repeat(gap(FRAME_W) │
│ )}{nextLabel && next && <Button label={nextL │
│ abel} href={`/notes/${next.id}/`} />}</Pre> │
│ <Pre class="frame-narrow">{prevLabel && │
│ prev && <Button label={prevLabel} href={`/n │
│ otes/${prev.id}/`} />}{" ".repeat(gap(MOBILE │
│ _FRAME_W))}{nextLabel && next && <Button lab │
│ el={nextLabel} href={`/notes/${next.id}/`} / │
│ >}</Pre> │
│ - </Fragment> │
│ + </nav> │
│ )} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Diff/Diff.astro ─────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Diff/Diff.astro b/ooknet-design/src/comp │
│ onents/Diff/Diff.astro │
│ new file mode 100644 │
│ index 0000000..75072ce │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Diff/Diff.astro │
│ @@ -0,0 +1,44 @@ │
│ +--- │
│ +// Emits the same markup as the ```diff pipeline output, so Prose's │
│ +// code-block styles and the layout's copy handler apply unchanged. │
│ +// Assembled as an HTML string: the Astro compiler can't nest expression │
│ +// children inside a raw <pre> tag. │
│ +import { G } from "../../lib/ascii"; │
│ +import { FRAME_W, MOBILE_FRAME_W } from "../../lib/config"; │
│ +import { layoutDiff, type Props } from "./Diff"; │
│ + │
│ +const { code } = Astro.props as Props; │
│ +const raw = code.replace(/\n+$/, ""); │
│ + │
│ +const esc = (s: string) => │
│ + s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); │
│ +const frame = (s: string) => │
│ + `<span class="code-frame" aria-hidden="true">${esc(s)}</span>`; │
│ + │
│ +function renderCode(w: number): string { │
│ + const parts: string[] = [ │
│ + frame(G.TL + G.H + " DIFF " + G.H.repeat(w - 12 - 6)), │
│ + '<button type="button" class="code-copy" aria-label="Copy code">[ COPY ]</but │
│ ton>', │
│ + frame(G.H + G.TR), │
│ + "\n", │
│ + ]; │
│ + for (const r of layoutDiff(code, w)) { │
│ + parts.push(frame(G.V + " ")); │
│ + parts.push(r.cls ? `<span class="${r.cls}">${esc(r.text)}</span>` : esc(r.tex │
│ t)); │
│ + parts.push(frame(" ".repeat(r.pad) + " " + G.V)); │
│ + parts.push("\n"); │
│ + } │
│ + parts.push(frame(G.BL + G.H.repeat(w - 2) + G.BR)); │
│ + return `<code>${parts.join("")}</code>`; │
│ +} │
│ + │
│ +const variants = [ │
│ + { cls: "frame-wide", w: FRAME_W }, │
│ + { cls: "frame-narrow", w: MOBILE_FRAME_W }, │
│ +]; │
│ +--- │
│ +<div class="code-frames prose"> │
│ + {variants.map(({ cls, w }) => ( │
│ + <pre class={"ascii code-block " + cls} data-raw-code={raw} set:html={renderCo │
│ de(w)} /> │
│ + ))} │
│ +</div> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...rc/components/Diff/Diff.astro ───┐
│ diff --git a/ooknet-design/src/components/Di │
│ ff/Diff.astro b/ooknet-design/src/components │
│ /Diff/Diff.astro │
│ new file mode 100644 │
│ index 0000000..75072ce │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Diff/Diff │
│ .astro │
│ @@ -0,0 +1,44 @@ │
│ +--- │
│ +// Emits the same markup as the ```diff pip │
│ eline output, so Prose's │
│ +// code-block styles and the layout's copy │
│ handler apply unchanged. │
│ +// Assembled as an HTML string: the Astro c │
│ ompiler can't nest expression │
│ +// children inside a raw <pre> tag. │
│ +import { G } from "../../lib/ascii"; │
│ +import { FRAME_W, MOBILE_FRAME_W } from ".. │
│ /../lib/config"; │
│ +import { layoutDiff, type Props } from "./D │
│ iff"; │
│ + │
│ +const { code } = Astro.props as Props; │
│ +const raw = code.replace(/\n+$/, ""); │
│ + │
│ +const esc = (s: string) => │
│ + s.replace(/&/g, "&").replace(/</g, "& │
│ lt;").replace(/>/g, ">"); │
│ +const frame = (s: string) => │
│ + `<span class="code-frame" aria-hidden="tr │
│ ue">${esc(s)}</span>`; │
│ + │
│ +function renderCode(w: number): string { │
│ + const parts: string[] = [ │
│ + frame(G.TL + G.H + " DIFF " + G.H.repea │
│ t(w - 12 - 6)), │
│ + '<button type="button" class="code-copy │
│ " aria-label="Copy code">[ COPY ]</button>', │
│ + frame(G.H + G.TR), │
│ + "\n", │
│ + ]; │
│ + for (const r of layoutDiff(code, w)) { │
│ + parts.push(frame(G.V + " ")); │
│ + parts.push(r.cls ? `<span class="${r.cl │
│ s}">${esc(r.text)}</span>` : esc(r.text)); │
│ + parts.push(frame(" ".repeat(r.pad) + " │
│ " + G.V)); │
│ + parts.push("\n"); │
│ + } │
│ + parts.push(frame(G.BL + G.H.repeat(w - 2) │
│ + G.BR)); │
│ + return `<code>${parts.join("")}</code>`; │
│ +} │
│ + │
│ +const variants = [ │
│ + { cls: "frame-wide", w: FRAME_W }, │
│ + { cls: "frame-narrow", w: MOBILE_FRAME_W │
│ }, │
│ +]; │
│ +--- │
│ +<div class="code-frames prose"> │
│ + {variants.map(({ cls, w }) => ( │
│ + <pre class={"ascii code-block " + cls} │
│ data-raw-code={raw} set:html={renderCode(w)} │
│ /> │
│ + ))} │
│ +</div> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Diff/Diff.test.ts ───────────────────────────┐
│ diff --git a/ooknet-design/src/components/Diff/Diff.test.ts b/ooknet-design/src/co │
│ mponents/Diff/Diff.test.ts │
│ new file mode 100644 │
│ index 0000000..d9bbc43 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Diff/Diff.test.ts │
│ @@ -0,0 +1,24 @@ │
│ +import { describe, expect, it } from "vitest"; │
│ +import { layoutDiff } from "./Diff"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +const DIFF = "@@ -1,2 +1,2 @@\n-old line\n+new line\n context"; │
│ + │
│ +describe("layoutDiff", () => { │
│ + it("classifies rows through the shared pipeline classifier", () => { │
│ + const rows = layoutDiff(DIFF, 48); │
│ + expect(rows.map((r) => r.cls)).toEqual(["diff-hunk", "diff-del", "diff-add", │
│ null]); │
│ + }); │
│ + │
│ + it("pads every row to the inner width", () => { │
│ + for (const r of layoutDiff(DIFF, 48)) { │
│ + expect(len(r.text) + r.pad).toBe(44); │
│ + } │
│ + }); │
│ + │
│ + it("keeps the class on wrapped continuation chunks", () => { │
│ + const rows = layoutDiff(`+${"x".repeat(100)}`, 48); │
│ + expect(rows.length).toBeGreaterThan(1); │
│ + expect(rows.every((r) => r.cls === "diff-add")).toBe(true); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../components/Diff/Diff.test.ts ───┐
│ diff --git a/ooknet-design/src/components/Di │
│ ff/Diff.test.ts b/ooknet-design/src/componen │
│ ts/Diff/Diff.test.ts │
│ new file mode 100644 │
│ index 0000000..d9bbc43 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Diff/Diff │
│ .test.ts │
│ @@ -0,0 +1,24 @@ │
│ +import { describe, expect, it } from "vites │
│ t"; │
│ +import { layoutDiff } from "./Diff"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +const DIFF = "@@ -1,2 +1,2 @@\n-old line\n+ │
│ new line\n context"; │
│ + │
│ +describe("layoutDiff", () => { │
│ + it("classifies rows through the shared pi │
│ peline classifier", () => { │
│ + const rows = layoutDiff(DIFF, 48); │
│ + expect(rows.map((r) => r.cls)).toEqual( │
│ ["diff-hunk", "diff-del", "diff-add", null]) │
│ ; │
│ + }); │
│ + │
│ + it("pads every row to the inner width", ( │
│ ) => { │
│ + for (const r of layoutDiff(DIFF, 48)) { │
│ + expect(len(r.text) + r.pad).toBe(44); │
│ + } │
│ + }); │
│ + │
│ + it("keeps the class on wrapped continuati │
│ on chunks", () => { │
│ + const rows = layoutDiff(`+${"x".repeat( │
│ 100)}`, 48); │
│ + expect(rows.length).toBeGreaterThan(1); │
│ + expect(rows.every((r) => r.cls === "dif │
│ f-add")).toBe(true); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Diff/Diff.ts ────────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Diff/Diff.ts b/ooknet-design/src/compone │
│ nts/Diff/Diff.ts │
│ new file mode 100644 │
│ index 0000000..48eea27 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Diff/Diff.ts │
│ @@ -0,0 +1,34 @@ │
│ +// Framed unified diff for use outside markdown - the component sibling │
│ +// of the ```diff fence. Shares the line classifier with the pipeline. │
│ +import { checkGrid, len } from "../../lib/ascii"; │
│ +import { diffLineClass } from "../../lib/rehype-ascii"; │
│ + │
│ +export interface Props { │
│ + code: string; │
│ +} │
│ + │
│ +export interface DiffRow { │
│ + text: string; │
│ + cls: string | null; │
│ + /** Trailing pad cells before the right border. */ │
│ + pad: number; │
│ +} │
│ + │
│ +export function layoutDiff(code: string, width: number): DiffRow[] { │
│ + const inner = width - 4; │
│ + return code │
│ + .replace(/\n+$/, "") │
│ + .split("\n") │
│ + .flatMap((line) => { │
│ + const cls = diffLineClass(line); │
│ + const cps = [...line]; │
│ + const chunks = cps.length <= inner │
│ + ? [line] │
│ + : Array.from({ length: Math.ceil(cps.length / inner) }, (_, i) => │
│ + cps.slice(i * inner, (i + 1) * inner).join("")); │
│ + return chunks.map((text) => { │
│ + checkGrid(text, inner, "diff row"); │
│ + return { text, cls, pad: inner - len(text) }; │
│ + }); │
│ + }); │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...n/src/components/Diff/Diff.ts ───┐
│ diff --git a/ooknet-design/src/components/Di │
│ ff/Diff.ts b/ooknet-design/src/components/Di │
│ ff/Diff.ts │
│ new file mode 100644 │
│ index 0000000..48eea27 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Diff/Diff │
│ .ts │
│ @@ -0,0 +1,34 @@ │
│ +// Framed unified diff for use outside mark │
│ down - the component sibling │
│ +// of the ```diff fence. Shares the line cl │
│ assifier with the pipeline. │
│ +import { checkGrid, len } from "../../lib/a │
│ scii"; │
│ +import { diffLineClass } from "../../lib/re │
│ hype-ascii"; │
│ + │
│ +export interface Props { │
│ + code: string; │
│ +} │
│ + │
│ +export interface DiffRow { │
│ + text: string; │
│ + cls: string | null; │
│ + /** Trailing pad cells before the right b │
│ order. */ │
│ + pad: number; │
│ +} │
│ + │
│ +export function layoutDiff(code: string, wi │
│ dth: number): DiffRow[] { │
│ + const inner = width - 4; │
│ + return code │
│ + .replace(/\n+$/, "") │
│ + .split("\n") │
│ + .flatMap((line) => { │
│ + const cls = diffLineClass(line); │
│ + const cps = [...line]; │
│ + const chunks = cps.length <= inner │
│ + ? [line] │
│ + : Array.from({ length: Math.ceil(cp │
│ s.length / inner) }, (_, i) => │
│ + cps.slice(i * inner, (i + 1) * │
│ inner).join("")); │
│ + return chunks.map((text) => { │
│ + checkGrid(text, inner, "diff row"); │
│ + return { text, cls, pad: inner - le │
│ n(text) }; │
│ + }); │
│ + }); │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Menu/Menu.astro ─────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Menu/Menu.astro b/ooknet-design/src/comp │
│ onents/Menu/Menu.astro │
│ new file mode 100644 │
│ index 0000000..28798f8 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Menu/Menu.astro │
│ @@ -0,0 +1,81 @@ │
│ +--- │
│ +// Dropdown menu: [ LABEL ↓ ] trigger over a framed popover of actions. │
│ +// ↓ instead of ? - it's the arrow the frame font ships (see Select). │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import { G, pad, rule, checkGrid } from "../../lib/ascii"; │
│ +import { menuCells, type Props } from "./Menu"; │
│ +import "./Menu.scss"; │
│ + │
│ +const { label, items, cells } = Astro.props as Props; │
│ +const inner = cells ?? menuCells(items); │
│ +const ctx = `Menu(${label})`; │
│ +checkGrid(items.map((it) => it.label).join("\n"), inner, ctx); │
│ +--- │
│ +<span class="menu"> │
│ + <button type="button" class="menu-trigger" aria-haspopup="menu" aria-expanded=" │
│ false">[ {label} ↓ ]</button> │
│ + <div class="menu-pop" role="menu" hidden> │
│ + <Pre>{G.TL}{rule(inner + 2)}{G.TR}</Pre> │
│ + {items.map((it) => ( │
│ + <Pre>{G.V}{" "}{it.href │
│ + ? <a class="menu-item" role="menuitem" href={it.href}>{pad(it.label, inne │
│ r)}</a> │
│ + : <button type="button" class="menu-item" role="menuitem">{pad(it.label, │
│ inner)}</button> │
│ + }{" "}{G.V}</Pre> │
│ + ))} │
│ + <Pre>{G.BL}{rule(inner + 2)}{G.BR}</Pre> │
│ + </div> │
│ +</span> │
│ + │
│ +<script> │
│ + function close(menu: HTMLElement) { │
│ + menu.querySelector<HTMLElement>(".menu-pop")!.hidden = true; │
│ + menu.querySelector(".menu-trigger")!.setAttribute("aria-expanded", "false"); │
│ + } │
│ + │
│ + function closeAll(except?: HTMLElement) { │
│ + document.querySelectorAll<HTMLElement>(".menu").forEach((m) => { │
│ + if (m !== except) close(m); │
│ + }); │
│ + } │
│ + │
│ + document.addEventListener("click", (e) => { │
│ + const target = e.target as HTMLElement; │
│ + const trigger = target.closest<HTMLElement>(".menu-trigger"); │
│ + if (trigger) { │
│ + const menu = trigger.closest<HTMLElement>(".menu")!; │
│ + const pop = menu.querySelector<HTMLElement>(".menu-pop")!; │
│ + closeAll(menu); │
│ + pop.hidden = !pop.hidden; │
│ + trigger.setAttribute("aria-expanded", String(!pop.hidden)); │
│ + if (!pop.hidden) pop.querySelector<HTMLElement>(".menu-item")?.focus(); │
│ + return; │
│ + } │
│ + if (target.closest(".menu-item")) { │
│ + const menu = target.closest<HTMLElement>(".menu"); │
│ + if (menu) close(menu); │
│ + return; │
│ + } │
│ + closeAll(); │
│ + }); │
│ + │
│ + document.addEventListener("keydown", (e) => { │
│ + const menu = (e.target as HTMLElement).closest?.<HTMLElement>(".menu"); │
│ + if (!menu) return; │
│ + const pop = menu.querySelector<HTMLElement>(".menu-pop")!; │
│ + if (pop.hidden) return; │
│ + │
│ + if (e.key === "Escape") { │
│ + e.preventDefault(); │
│ + close(menu); │
│ + menu.querySelector<HTMLElement>(".menu-trigger")?.focus(); │
│ + return; │
│ + } │
│ + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; │
│ + e.preventDefault(); │
│ + const menuItems = Array.from(pop.querySelectorAll<HTMLElement>(".menu-item")) │
│ ; │
│ + const idx = menuItems.indexOf(document.activeElement as HTMLElement); │
│ + const next = e.key === "ArrowDown" │
│ + ? Math.min(menuItems.length - 1, idx + 1) │
│ + : Math.max(0, idx - 1); │
│ + menuItems[next]?.focus(); │
│ + }); │
│ +</script> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...rc/components/Menu/Menu.astro ───┐
│ diff --git a/ooknet-design/src/components/Me │
│ nu/Menu.astro b/ooknet-design/src/components │
│ /Menu/Menu.astro │
│ new file mode 100644 │
│ index 0000000..28798f8 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Menu/Menu │
│ .astro │
│ @@ -0,0 +1,81 @@ │
│ +--- │
│ +// Dropdown menu: [ LABEL ↓ ] trigger over │
│ a framed popover of actions. │
│ +// ↓ instead of ? - it's the arrow the fram │
│ e font ships (see Select). │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import { G, pad, rule, checkGrid } from ".. │
│ /../lib/ascii"; │
│ +import { menuCells, type Props } from "./Me │
│ nu"; │
│ +import "./Menu.scss"; │
│ + │
│ +const { label, items, cells } = Astro.props │
│ as Props; │
│ +const inner = cells ?? menuCells(items); │
│ +const ctx = `Menu(${label})`; │
│ +checkGrid(items.map((it) => it.label).join( │
│ "\n"), inner, ctx); │
│ +--- │
│ +<span class="menu"> │
│ + <button type="button" class="menu-trigger │
│ " aria-haspopup="menu" aria-expanded="false" │
│ >[ {label} ↓ ]</button> │
│ + <div class="menu-pop" role="menu" hidden> │
│ + <Pre>{G.TL}{rule(inner + 2)}{G.TR}</Pre │
│ > │
│ + {items.map((it) => ( │
│ + <Pre>{G.V}{" "}{it.href │
│ + ? <a class="menu-item" role="menuit │
│ em" href={it.href}>{pad(it.label, inner)}</a │
│ > │
│ + : <button type="button" class="menu │
│ -item" role="menuitem">{pad(it.label, inner) │
│ }</button> │
│ + }{" "}{G.V}</Pre> │
│ + ))} │
│ + <Pre>{G.BL}{rule(inner + 2)}{G.BR}</Pre │
│ > │
│ + </div> │
│ +</span> │
│ + │
│ +<script> │
│ + function close(menu: HTMLElement) { │
│ + menu.querySelector<HTMLElement>(".menu- │
│ pop")!.hidden = true; │
│ + menu.querySelector(".menu-trigger")!.se │
│ tAttribute("aria-expanded", "false"); │
│ + } │
│ + │
│ + function closeAll(except?: HTMLElement) { │
│ + document.querySelectorAll<HTMLElement>( │
│ ".menu").forEach((m) => { │
│ + if (m !== except) close(m); │
│ + }); │
│ + } │
│ + │
│ + document.addEventListener("click", (e) => │
│ { │
│ + const target = e.target as HTMLElement; │
│ + const trigger = target.closest<HTMLElem │
│ ent>(".menu-trigger"); │
│ + if (trigger) { │
│ + const menu = trigger.closest<HTMLElem │
│ ent>(".menu")!; │
│ + const pop = menu.querySelector<HTMLEl │
│ ement>(".menu-pop")!; │
│ + closeAll(menu); │
│ + pop.hidden = !pop.hidden; │
│ + trigger.setAttribute("aria-expanded", │
│ String(!pop.hidden)); │
│ + if (!pop.hidden) pop.querySelector<HT │
│ MLElement>(".menu-item")?.focus(); │
│ + return; │
│ + } │
│ + if (target.closest(".menu-item")) { │
│ + const menu = target.closest<HTMLEleme │
│ nt>(".menu"); │
│ + if (menu) close(menu); │
│ + return; │
│ + } │
│ + closeAll(); │
│ + }); │
│ + │
│ + document.addEventListener("keydown", (e) │
│ => { │
│ + const menu = (e.target as HTMLElement). │
│ closest?.<HTMLElement>(".menu"); │
│ + if (!menu) return; │
│ + const pop = menu.querySelector<HTMLElem │
│ ent>(".menu-pop")!; │
│ + if (pop.hidden) return; │
│ + │
│ + if (e.key === "Escape") { │
│ + e.preventDefault(); │
│ + close(menu); │
│ + menu.querySelector<HTMLElement>(".men │
│ u-trigger")?.focus(); │
│ + return; │
│ + } │
│ + if (e.key !== "ArrowDown" && e.key !== │
│ "ArrowUp") return; │
│ + e.preventDefault(); │
│ + const menuItems = Array.from(pop.queryS │
│ electorAll<HTMLElement>(".menu-item")); │
│ + const idx = menuItems.indexOf(document. │
│ activeElement as HTMLElement); │
│ + const next = e.key === "ArrowDown" │
│ + ? Math.min(menuItems.length - 1, idx │
│ + 1) │
│ + : Math.max(0, idx - 1); │
│ + menuItems[next]?.focus(); │
│ + }); │
│ +</script> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Menu/Menu.scss ──────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Menu/Menu.scss b/ooknet-design/src/compo │
│ nents/Menu/Menu.scss │
│ new file mode 100644 │
│ index 0000000..b903642 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Menu/Menu.scss │
│ @@ -0,0 +1,65 @@ │
│ +.menu { │
│ + position: relative; │
│ + display: inline-block; │
│ + │
│ + // Not on .menu itself: template newlines between the popover's rows │
│ + // would render as blank lines inside the box. │
│ + .menu-trigger { │
│ + 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; │
│ + } │
│ + │
│ + &[aria-expanded="true"] { │
│ + font-weight: 700; │
│ + background: var(--ink); │
│ + color: var(--paper); │
│ + } │
│ + } │
│ + │
│ + .menu-pop { │
│ + position: absolute; │
│ + top: 100%; │
│ + left: 0; │
│ + z-index: 10; │
│ + background: var(--paper); │
│ + box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.3); │
│ + │
│ + pre.ascii { │
│ + width: auto; │
│ + } │
│ + } │
│ + │
│ + .menu-item { │
│ + font: inherit; │
│ + color: var(--ink); │
│ + background: none; │
│ + border: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + cursor: pointer; │
│ + white-space: pre; │
│ + text-decoration: none; │
│ + │
│ + &:hover, │
│ + &:focus-visible { │
│ + outline: 0; │
│ + background: var(--ink); │
│ + color: var(--paper); │
│ + } │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...src/components/Menu/Menu.scss ───┐
│ diff --git a/ooknet-design/src/components/Me │
│ nu/Menu.scss b/ooknet-design/src/components/ │
│ Menu/Menu.scss │
│ new file mode 100644 │
│ index 0000000..b903642 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Menu/Menu │
│ .scss │
│ @@ -0,0 +1,65 @@ │
│ +.menu { │
│ + position: relative; │
│ + display: inline-block; │
│ + │
│ + // Not on .menu itself: template newlines │
│ between the popover's rows │
│ + // would render as blank lines inside the │
│ box. │
│ + .menu-trigger { │
│ + 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; │
│ + } │
│ + │
│ + &[aria-expanded="true"] { │
│ + font-weight: 700; │
│ + background: var(--ink); │
│ + color: var(--paper); │
│ + } │
│ + } │
│ + │
│ + .menu-pop { │
│ + position: absolute; │
│ + top: 100%; │
│ + left: 0; │
│ + z-index: 10; │
│ + background: var(--paper); │
│ + box-shadow: 0 8px 24px -12px rgba(0, 0, │
│ 0, 0.3); │
│ + │
│ + pre.ascii { │
│ + width: auto; │
│ + } │
│ + } │
│ + │
│ + .menu-item { │
│ + font: inherit; │
│ + color: var(--ink); │
│ + background: none; │
│ + border: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + cursor: pointer; │
│ + white-space: pre; │
│ + text-decoration: none; │
│ + │
│ + &:hover, │
│ + &:focus-visible { │
│ + outline: 0; │
│ + background: var(--ink); │
│ + color: var(--paper); │
│ + } │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Menu/Menu.ts ────────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Menu/Menu.ts b/ooknet-design/src/compone │
│ nts/Menu/Menu.ts │
│ new file mode 100644 │
│ index 0000000..6aa88b7 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Menu/Menu.ts │
│ @@ -0,0 +1,16 @@ │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +export interface MenuItem { │
│ + label: string; │
│ + href?: string; │
│ +} │
│ + │
│ +export interface Props { │
│ + label: string; │
│ + items: MenuItem[]; │
│ + /** Inner width in cells; defaults to the widest item label. */ │
│ + cells?: number; │
│ +} │
│ + │
│ +export const menuCells = (items: MenuItem[]) => │
│ + Math.max(1, ...items.map((it) => len(it.label))); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...n/src/components/Menu/Menu.ts ───┐
│ diff --git a/ooknet-design/src/components/Me │
│ nu/Menu.ts b/ooknet-design/src/components/Me │
│ nu/Menu.ts │
│ new file mode 100644 │
│ index 0000000..6aa88b7 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Menu/Menu │
│ .ts │
│ @@ -0,0 +1,16 @@ │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +export interface MenuItem { │
│ + label: string; │
│ + href?: string; │
│ +} │
│ + │
│ +export interface Props { │
│ + label: string; │
│ + items: MenuItem[]; │
│ + /** Inner width in cells; defaults to the │
│ widest item label. */ │
│ + cells?: number; │
│ +} │
│ + │
│ +export const menuCells = (items: MenuItem[] │
│ ) => │
│ + Math.max(1, ...items.map((it) => len(it.l │
│ abel))); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Skeleton/Skeleton.astro ─────────────────────┐
│ diff --git a/ooknet-design/src/components/Skeleton/Skeleton.astro b/ooknet-design/ │
│ src/components/Skeleton/Skeleton.astro │
│ new file mode 100644 │
│ index 0000000..80bdead │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Skeleton/Skeleton.astro │
│ @@ -0,0 +1,12 @@ │
│ +--- │
│ +// Loading placeholder: pulsing rows of ░. Static-site pages rarely │
│ +// need it; client-fetched panels do. │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import type { Props } from "./Skeleton"; │
│ +import "./Skeleton.scss"; │
│ + │
│ +const { rows = [24, 18, 21] } = Astro.props as Props; │
│ +--- │
│ +<div class="skeleton" aria-hidden="true"> │
│ + {rows.map((w) => <Pre class="skeleton-row">{"░".repeat(w)}</Pre>)} │
│ +</div> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nents/Skeleton/Skeleton.astro ───┐
│ diff --git a/ooknet-design/src/components/Sk │
│ eleton/Skeleton.astro b/ooknet-design/src/co │
│ mponents/Skeleton/Skeleton.astro │
│ new file mode 100644 │
│ index 0000000..80bdead │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Skeleton/ │
│ Skeleton.astro │
│ @@ -0,0 +1,12 @@ │
│ +--- │
│ +// Loading placeholder: pulsing rows of ░. │
│ Static-site pages rarely │
│ +// need it; client-fetched panels do. │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import type { Props } from "./Skeleton"; │
│ +import "./Skeleton.scss"; │
│ + │
│ +const { rows = [24, 18, 21] } = Astro.props │
│ as Props; │
│ +--- │
│ +<div class="skeleton" aria-hidden="true"> │
│ + {rows.map((w) => <Pre class="skeleton-row │
│ ">{"░".repeat(w)}</Pre>)} │
│ +</div> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Skeleton/Skeleton.scss ──────────────────────┐
│ diff --git a/ooknet-design/src/components/Skeleton/Skeleton.scss b/ooknet-design/s │
│ rc/components/Skeleton/Skeleton.scss │
│ new file mode 100644 │
│ index 0000000..675b512 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Skeleton/Skeleton.scss │
│ @@ -0,0 +1,18 @@ │
│ +.skeleton { │
│ + .skeleton-row { │
│ + color: var(--ink-soft); │
│ + animation: skeleton-pulse 1.4s ease-in-out infinite; │
│ + } │
│ + │
│ + @media (prefers-reduced-motion: reduce) { │
│ + .skeleton-row { │
│ + animation: none; │
│ + opacity: 0.6; │
│ + } │
│ + } │
│ +} │
│ + │
│ +@keyframes skeleton-pulse { │
│ + 0%, 100% { opacity: 0.35; } │
│ + 50% { opacity: 0.8; } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...onents/Skeleton/Skeleton.scss ───┐
│ diff --git a/ooknet-design/src/components/Sk │
│ eleton/Skeleton.scss b/ooknet-design/src/com │
│ ponents/Skeleton/Skeleton.scss │
│ new file mode 100644 │
│ index 0000000..675b512 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Skeleton/ │
│ Skeleton.scss │
│ @@ -0,0 +1,18 @@ │
│ +.skeleton { │
│ + .skeleton-row { │
│ + color: var(--ink-soft); │
│ + animation: skeleton-pulse 1.4s ease-in- │
│ out infinite; │
│ + } │
│ + │
│ + @media (prefers-reduced-motion: reduce) { │
│ + .skeleton-row { │
│ + animation: none; │
│ + opacity: 0.6; │
│ + } │
│ + } │
│ +} │
│ + │
│ +@keyframes skeleton-pulse { │
│ + 0%, 100% { opacity: 0.35; } │
│ + 50% { opacity: 0.8; } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Skeleton/Skeleton.ts ────────────────────────┐
│ diff --git a/ooknet-design/src/components/Skeleton/Skeleton.ts b/ooknet-design/src │
│ /components/Skeleton/Skeleton.ts │
│ new file mode 100644 │
│ index 0000000..aa08145 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Skeleton/Skeleton.ts │
│ @@ -0,0 +1,4 @@ │
│ +export interface Props { │
│ + /** Cell width of each placeholder row. */ │
│ + rows?: number[]; │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...mponents/Skeleton/Skeleton.ts ───┐
│ diff --git a/ooknet-design/src/components/Sk │
│ eleton/Skeleton.ts b/ooknet-design/src/compo │
│ nents/Skeleton/Skeleton.ts │
│ new file mode 100644 │
│ index 0000000..aa08145 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Skeleton/ │
│ Skeleton.ts │
│ @@ -0,0 +1,4 @@ │
│ +export interface Props { │
│ + /** Cell width of each placeholder row. * │
│ / │
│ + rows?: number[]; │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Slider/Slider.astro ─────────────────────────┐
│ diff --git a/ooknet-design/src/components/Slider/Slider.astro b/ooknet-design/src/ │
│ components/Slider/Slider.astro │
│ new file mode 100644 │
│ index 0000000..6181f0f │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Slider/Slider.astro │
│ @@ -0,0 +1,32 @@ │
│ +--- │
│ +// Native range input overlaid on an ASCII track; the script re-renders │
│ +// the track text on input. │
│ +import { buildSliderTrack, type Props } from "./Slider"; │
│ +import "./Slider.scss"; │
│ + │
│ +const { name, value = 50, min = 0, max = 100, step = 1, cells = 20, showValue = t │
│ rue } = Astro.props as Props; │
│ +--- │
│ +<span class="slider" style={`--slider-cells: ${cells}`}><span class="slider-track │
│ " aria-hidden="true" data-cells={cells}>{buildSliderTrack(value, min, max, cells)} │
│ </span><input │
│ + type="range" │
│ + name={name} │
│ + value={value} │
│ + min={min} │
│ + max={max} │
│ + step={step} │
│ + aria-label={name} │
│ +/>{showValue && <span class="slider-value">{" " + String(value).padStart(3)}</spa │
│ n>}</span> │
│ + │
│ +<script> │
│ + import { buildSliderTrack } from "./Slider"; │
│ + │
│ + document.addEventListener("input", (e) => { │
│ + const input = (e.target as HTMLElement).closest?.<HTMLInputElement>(".slider │
│ input[type=range]"); │
│ + if (!input) return; │
│ + const slider = input.closest(".slider")!; │
│ + const track = slider.querySelector<HTMLElement>(".slider-track")!; │
│ + const cells = Number(track.dataset.cells) || 20; │
│ + track.textContent = buildSliderTrack(Number(input.value), Number(input.min), │
│ Number(input.max), cells); │
│ + const readout = slider.querySelector(".slider-value"); │
│ + if (readout) readout.textContent = " " + String(input.value).padStart(3); │
│ + }); │
│ +</script> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...omponents/Slider/Slider.astro ───┐
│ diff --git a/ooknet-design/src/components/Sl │
│ ider/Slider.astro b/ooknet-design/src/compon │
│ ents/Slider/Slider.astro │
│ new file mode 100644 │
│ index 0000000..6181f0f │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Slider/Sl │
│ ider.astro │
│ @@ -0,0 +1,32 @@ │
│ +--- │
│ +// Native range input overlaid on an ASCII │
│ track; the script re-renders │
│ +// the track text on input. │
│ +import { buildSliderTrack, type Props } fro │
│ m "./Slider"; │
│ +import "./Slider.scss"; │
│ + │
│ +const { name, value = 50, min = 0, max = 10 │
│ 0, step = 1, cells = 20, showValue = true } │
│ = Astro.props as Props; │
│ +--- │
│ +<span class="slider" style={`--slider-cells │
│ : ${cells}`}><span class="slider-track" aria │
│ -hidden="true" data-cells={cells}>{buildSlid │
│ erTrack(value, min, max, cells)}</span><inpu │
│ t │
│ + type="range" │
│ + name={name} │
│ + value={value} │
│ + min={min} │
│ + max={max} │
│ + step={step} │
│ + aria-label={name} │
│ +/>{showValue && <span class="slider-value"> │
│ {" " + String(value).padStart(3)}</span>}</s │
│ pan> │
│ + │
│ +<script> │
│ + import { buildSliderTrack } from "./Slide │
│ r"; │
│ + │
│ + document.addEventListener("input", (e) => │
│ { │
│ + const input = (e.target as HTMLElement) │
│ .closest?.<HTMLInputElement>(".slider input[ │
│ type=range]"); │
│ + if (!input) return; │
│ + const slider = input.closest(".slider") │
│ !; │
│ + const track = slider.querySelector<HTML │
│ Element>(".slider-track")!; │
│ + const cells = Number(track.dataset.cell │
│ s) || 20; │
│ + track.textContent = buildSliderTrack(Nu │
│ mber(input.value), Number(input.min), Number │
│ (input.max), cells); │
│ + const readout = slider.querySelector(". │
│ slider-value"); │
│ + if (readout) readout.textContent = " " │
│ + String(input.value).padStart(3); │
│ + }); │
│ +</script> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Slider/Slider.scss ──────────────────────────┐
│ diff --git a/ooknet-design/src/components/Slider/Slider.scss b/ooknet-design/src/c │
│ omponents/Slider/Slider.scss │
│ new file mode 100644 │
│ index 0000000..b908760 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Slider/Slider.scss │
│ @@ -0,0 +1,29 @@ │
│ +.slider { │
│ + position: relative; │
│ + white-space: pre; │
│ + │
│ + .slider-track { │
│ + font-family: var(--mono-frame); │
│ + } │
│ + │
│ + // The native input sits invisibly over the track for interaction. │
│ + input[type="range"] { │
│ + position: absolute; │
│ + left: 0; │
│ + top: 0; │
│ + width: calc(var(--cell-w) * var(--slider-cells)); │
│ + height: 100%; │
│ + margin: 0; │
│ + opacity: 0; │
│ + cursor: pointer; │
│ + } │
│ + │
│ + &:has(input:focus-visible) .slider-track { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 2px; │
│ + } │
│ + │
│ + .slider-value { │
│ + font-variant-numeric: tabular-nums; │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...components/Slider/Slider.scss ───┐
│ diff --git a/ooknet-design/src/components/Sl │
│ ider/Slider.scss b/ooknet-design/src/compone │
│ nts/Slider/Slider.scss │
│ new file mode 100644 │
│ index 0000000..b908760 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Slider/Sl │
│ ider.scss │
│ @@ -0,0 +1,29 @@ │
│ +.slider { │
│ + position: relative; │
│ + white-space: pre; │
│ + │
│ + .slider-track { │
│ + font-family: var(--mono-frame); │
│ + } │
│ + │
│ + // The native input sits invisibly over t │
│ he track for interaction. │
│ + input[type="range"] { │
│ + position: absolute; │
│ + left: 0; │
│ + top: 0; │
│ + width: calc(var(--cell-w) * var(--slide │
│ r-cells)); │
│ + height: 100%; │
│ + margin: 0; │
│ + opacity: 0; │
│ + cursor: pointer; │
│ + } │
│ + │
│ + &:has(input:focus-visible) .slider-track │
│ { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 2px; │
│ + } │
│ + │
│ + .slider-value { │
│ + font-variant-numeric: tabular-nums; │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Slider/Slider.test.ts ───────────────────────┐
│ diff --git a/ooknet-design/src/components/Slider/Slider.test.ts b/ooknet-design/sr │
│ c/components/Slider/Slider.test.ts │
│ new file mode 100644 │
│ index 0000000..acfe790 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Slider/Slider.test.ts │
│ @@ -0,0 +1,24 @@ │
│ +import { describe, expect, it } from "vitest"; │
│ +import { buildSliderTrack } from "./Slider"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +describe("buildSliderTrack", () => { │
│ + it("renders the exact cell width", () => { │
│ + expect(len(buildSliderTrack(50, 0, 100, 20))).toBe(20); │
│ + }); │
│ + │
│ + it("pins the thumb to the ends at min and max", () => { │
│ + expect(buildSliderTrack(0, 0, 100, 12)).toBe("├█─────────┤"); │
│ + expect(buildSliderTrack(100, 0, 100, 12)).toBe("├─────────█┤"); │
│ + }); │
│ + │
│ + it("centers the thumb at the midpoint", () => { │
│ + const t = [...buildSliderTrack(50, 0, 100, 13)]; │
│ + expect(t[6]).toBe("█"); │
│ + }); │
│ + │
│ + it("clamps out-of-range values", () => { │
│ + expect(buildSliderTrack(-10, 0, 100, 12)).toBe(buildSliderTrack(0, 0, 100, 12 │
│ )); │
│ + expect(buildSliderTrack(250, 0, 100, 12)).toBe(buildSliderTrack(100, 0, 100, │
│ 12)); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ponents/Slider/Slider.test.ts ───┐
│ diff --git a/ooknet-design/src/components/Sl │
│ ider/Slider.test.ts b/ooknet-design/src/comp │
│ onents/Slider/Slider.test.ts │
│ new file mode 100644 │
│ index 0000000..acfe790 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Slider/Sl │
│ ider.test.ts │
│ @@ -0,0 +1,24 @@ │
│ +import { describe, expect, it } from "vites │
│ t"; │
│ +import { buildSliderTrack } from "./Slider" │
│ ; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +describe("buildSliderTrack", () => { │
│ + it("renders the exact cell width", () => │
│ { │
│ + expect(len(buildSliderTrack(50, 0, 100, │
│ 20))).toBe(20); │
│ + }); │
│ + │
│ + it("pins the thumb to the ends at min and │
│ max", () => { │
│ + expect(buildSliderTrack(0, 0, 100, 12)) │
│ .toBe("├█─────────┤"); │
│ + expect(buildSliderTrack(100, 0, 100, 12 │
│ )).toBe("├─────────█┤"); │
│ + }); │
│ + │
│ + it("centers the thumb at the midpoint", ( │
│ ) => { │
│ + const t = [...buildSliderTrack(50, 0, 1 │
│ 00, 13)]; │
│ + expect(t[6]).toBe("█"); │
│ + }); │
│ + │
│ + it("clamps out-of-range values", () => { │
│ + expect(buildSliderTrack(-10, 0, 100, 12 │
│ )).toBe(buildSliderTrack(0, 0, 100, 12)); │
│ + expect(buildSliderTrack(250, 0, 100, 12 │
│ )).toBe(buildSliderTrack(100, 0, 100, 12)); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Slider/Slider.ts ────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Slider/Slider.ts b/ooknet-design/src/com │
│ ponents/Slider/Slider.ts │
│ new file mode 100644 │
│ index 0000000..e4c7251 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Slider/Slider.ts │
│ @@ -0,0 +1,22 @@ │
│ +// Track text for the range slider: ├────█────┤. Runs in the browser │
│ +// too (the component script re-renders it on input). │
│ +export interface Props { │
│ + name: string; │
│ + value?: number; │
│ + min?: number; │
│ + max?: number; │
│ + step?: number; │
│ + /** Track width in cells, including the ├ ┤ ends. */ │
│ + cells?: number; │
│ + /** Render the numeric value after the track. */ │
│ + showValue?: boolean; │
│ +} │
│ + │
│ +export function buildSliderTrack(value: number, min: number, max: number, cells = │
│ 20): string { │
│ + const inner = Math.max(1, cells - 2); │
│ + const span = max - min || 1; │
│ + const ratio = Math.max(0, Math.min(1, (value - min) / span)); │
│ + const pos = Math.round(ratio * (inner - 1)); │
│ + const track = Array.from({ length: inner }, (_, i) => (i === pos ? "█" : "─")); │
│ + return `├${track.join("")}┤`; │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/components/Slider/Slider.ts ───┐
│ diff --git a/ooknet-design/src/components/Sl │
│ ider/Slider.ts b/ooknet-design/src/component │
│ s/Slider/Slider.ts │
│ new file mode 100644 │
│ index 0000000..e4c7251 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Slider/Sl │
│ ider.ts │
│ @@ -0,0 +1,22 @@ │
│ +// Track text for the range slider: ├────█─ │
│ ───┤. Runs in the browser │
│ +// too (the component script re-renders it │
│ on input). │
│ +export interface Props { │
│ + name: string; │
│ + value?: number; │
│ + min?: number; │
│ + max?: number; │
│ + step?: number; │
│ + /** Track width in cells, including the ├ │
│ ┤ ends. */ │
│ + cells?: number; │
│ + /** Render the numeric value after the tr │
│ ack. */ │
│ + showValue?: boolean; │
│ +} │
│ + │
│ +export function buildSliderTrack(value: num │
│ ber, min: number, max: number, cells = 20): │
│ string { │
│ + const inner = Math.max(1, cells - 2); │
│ + const span = max - min || 1; │
│ + const ratio = Math.max(0, Math.min(1, (va │
│ lue - min) / span)); │
│ + const pos = Math.round(ratio * (inner - 1 │
│ )); │
│ + const track = Array.from({ length: inner │
│ }, (_, i) => (i === pos ? "█" : "─")); │
│ + return `├${track.join("")}┤`; │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Tooltip/Tooltip.astro ───────────────────────┐
│ diff --git a/ooknet-design/src/components/Tooltip/Tooltip.astro b/ooknet-design/sr │
│ c/components/Tooltip/Tooltip.astro │
│ new file mode 100644 │
│ index 0000000..48c218a │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tooltip/Tooltip.astro │
│ @@ -0,0 +1,15 @@ │
│ +--- │
│ +// Hover/focus tooltip. The text is known at build time, so the box is │
│ +// framed exactly - no runtime measurement. │
│ +import { box, len, wrap } from "../../lib/ascii"; │
│ +import { TOOLTIP_MAX_W, type Props } from "./Tooltip"; │
│ +import "./Tooltip.scss"; │
│ + │
│ +const { text, label = "?" } = Astro.props as Props; │
│ +const inner = Math.min(TOOLTIP_MAX_W - 4, len(text)); │
│ +const lines = wrap(text, inner).split("\n"); │
│ +const width = Math.max(...lines.map(len)) + 4; │
│ +const frame = box({ width, lines: lines.map((l) => ` ${l}`), align: "end" }); │
│ +const id = `tip-${text.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 32)}`; │
│ +--- │
│ +<span class="tooltip"><button type="button" class="tooltip-trigger" aria-describe │
│ dby={id}>[{label}]</button><span class="tooltip-pop" role="tooltip" id={id}><pre c │
│ lass="ascii tooltip-frame" aria-hidden="true">{frame}</pre><span class="sr-only">{ │
│ text}</span></span></span> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ponents/Tooltip/Tooltip.astro ───┐
│ diff --git a/ooknet-design/src/components/To │
│ oltip/Tooltip.astro b/ooknet-design/src/comp │
│ onents/Tooltip/Tooltip.astro │
│ new file mode 100644 │
│ index 0000000..48c218a │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tooltip/T │
│ ooltip.astro │
│ @@ -0,0 +1,15 @@ │
│ +--- │
│ +// Hover/focus tooltip. The text is known a │
│ t build time, so the box is │
│ +// framed exactly - no runtime measurement. │
│ +import { box, len, wrap } from "../../lib/a │
│ scii"; │
│ +import { TOOLTIP_MAX_W, type Props } from " │
│ ./Tooltip"; │
│ +import "./Tooltip.scss"; │
│ + │
│ +const { text, label = "?" } = Astro.props a │
│ s Props; │
│ +const inner = Math.min(TOOLTIP_MAX_W - 4, l │
│ en(text)); │
│ +const lines = wrap(text, inner).split("\n") │
│ ; │
│ +const width = Math.max(...lines.map(len)) + │
│ 4; │
│ +const frame = box({ width, lines: lines.map │
│ ((l) => ` ${l}`), align: "end" }); │
│ +const id = `tip-${text.toLowerCase().replac │
│ e(/[^a-z0-9]+/g, "-").slice(0, 32)}`; │
│ +--- │
│ +<span class="tooltip"><button type="button" │
│ class="tooltip-trigger" aria-describedby={i │
│ d}>[{label}]</button><span class="tooltip-po │
│ p" role="tooltip" id={id}><pre class="ascii │
│ tooltip-frame" aria-hidden="true">{frame}</p │
│ re><span class="sr-only">{text}</span></span │
│ ></span> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Tooltip/Tooltip.scss ────────────────────────┐
│ diff --git a/ooknet-design/src/components/Tooltip/Tooltip.scss b/ooknet-design/src │
│ /components/Tooltip/Tooltip.scss │
│ new file mode 100644 │
│ index 0000000..f85e1ca │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tooltip/Tooltip.scss │
│ @@ -0,0 +1,44 @@ │
│ +.tooltip { │
│ + position: relative; │
│ + display: inline-block; │
│ + white-space: pre; │
│ + │
│ + .tooltip-trigger { │
│ + font: inherit; │
│ + color: var(--ink-soft); │
│ + background: none; │
│ + border: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + cursor: help; │
│ + white-space: pre; │
│ + │
│ + &:hover { │
│ + color: var(--ink); │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + } │
│ + │
│ + .tooltip-pop { │
│ + position: absolute; │
│ + bottom: 100%; │
│ + left: 0; │
│ + z-index: 10; │
│ + display: none; │
│ + background: var(--paper); │
│ + box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.3); │
│ + │
│ + pre.ascii.tooltip-frame { │
│ + width: auto; │
│ + } │
│ + } │
│ + │
│ + &:hover .tooltip-pop, │
│ + &:focus-within .tooltip-pop { │
│ + display: block; │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...mponents/Tooltip/Tooltip.scss ───┐
│ diff --git a/ooknet-design/src/components/To │
│ oltip/Tooltip.scss b/ooknet-design/src/compo │
│ nents/Tooltip/Tooltip.scss │
│ new file mode 100644 │
│ index 0000000..f85e1ca │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tooltip/T │
│ ooltip.scss │
│ @@ -0,0 +1,44 @@ │
│ +.tooltip { │
│ + position: relative; │
│ + display: inline-block; │
│ + white-space: pre; │
│ + │
│ + .tooltip-trigger { │
│ + font: inherit; │
│ + color: var(--ink-soft); │
│ + background: none; │
│ + border: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + cursor: help; │
│ + white-space: pre; │
│ + │
│ + &:hover { │
│ + color: var(--ink); │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + } │
│ + │
│ + .tooltip-pop { │
│ + position: absolute; │
│ + bottom: 100%; │
│ + left: 0; │
│ + z-index: 10; │
│ + display: none; │
│ + background: var(--paper); │
│ + box-shadow: 0 8px 24px -12px rgba(0, 0, │
│ 0, 0.3); │
│ + │
│ + pre.ascii.tooltip-frame { │
│ + width: auto; │
│ + } │
│ + } │
│ + │
│ + &:hover .tooltip-pop, │
│ + &:focus-within .tooltip-pop { │
│ + display: block; │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Tooltip/Tooltip.ts ──────────────────────────┐
│ diff --git a/ooknet-design/src/components/Tooltip/Tooltip.ts b/ooknet-design/src/c │
│ omponents/Tooltip/Tooltip.ts │
│ new file mode 100644 │
│ index 0000000..4bc5585 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tooltip/Tooltip.ts │
│ @@ -0,0 +1,8 @@ │
│ +export interface Props { │
│ + /** Tooltip body; wrapped into a box at build time. */ │
│ + text: string; │
│ + /** Trigger label, defaults to "?" ? renders [?]. */ │
│ + label?: string; │
│ +} │
│ + │
│ +export const TOOLTIP_MAX_W = 40; │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...components/Tooltip/Tooltip.ts ───┐
│ diff --git a/ooknet-design/src/components/To │
│ oltip/Tooltip.ts b/ooknet-design/src/compone │
│ nts/Tooltip/Tooltip.ts │
│ new file mode 100644 │
│ index 0000000..4bc5585 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tooltip/T │
│ ooltip.ts │
│ @@ -0,0 +1,8 @@ │
│ +export interface Props { │
│ + /** Tooltip body; wrapped into a box at b │
│ uild time. */ │
│ + text: string; │
│ + /** Trigger label, defaults to "?" ? rend │
│ ers [?]. */ │
│ + label?: string; │
│ +} │
│ + │
│ +export const TOOLTIP_MAX_W = 40; │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/TopHeader/TopHeader.astro ───────────────────┐
│ diff --git a/ooknet-design/src/components/TopHeader/TopHeader.astro b/ooknet-desig │
│ n/src/components/TopHeader/TopHeader.astro │
│ index 0a19b5d..5b5c877 100644 │
│ --- a/ooknet-design/src/components/TopHeader/TopHeader.astro │
│ +++ b/ooknet-design/src/components/TopHeader/TopHeader.astro │
│ @@ -19,7 +19,7 @@ const toggleGap = " ".repeat(MASTHEAD_TOGGLE_GAP); │
│ const PLACEHOLDER_WIDE = "_".repeat(20); │
│ const PLACEHOLDER_NARROW = "_".repeat(18); │
│ --- │
│ -<div class="frame-wide"> │
│ +<div class="frame-wide top-header"> │
│ <div class="masthead"> │
│ <Pre><a href="/" class="masthead-home">{MASTHEAD_LABEL}</a>{padW}<span class= │
│ "masthead-search">[ / search the index <input class="masthead-search-input" type= │
│ "text" aria-label="Search notes" autocomplete="off" spellcheck="false" placeholder │
│ ={PLACEHOLDER_WIDE} maxlength="20" data-search-input data-cells="20" /> ]</span>{t │
│ oggleGap}<ThemeToggle /></Pre> │
│ <ul class="masthead-search-results" hidden data-search-results></ul> │
│ @@ -28,7 +28,7 @@ const PLACEHOLDER_NARROW = "_".repeat(18); │
│ <NavBar width={FRAME_W} /> │
│ <Pre>{ruleD(FRAME_W)}</Pre> │
│ </div> │
│ -<div class="frame-narrow"> │
│ +<div class="frame-narrow top-header"> │
│ <div class="masthead"> │
│ <Pre><a href="/" class="masthead-home">{MASTHEAD_LABEL}</a>{padN}<span class= │
│ "masthead-search">[ / search <input class="masthead-search-input" type="text" ari │
│ a-label="Search notes" autocomplete="off" spellcheck="false" placeholder={PLACEHOL │
│ DER_NARROW} maxlength="18" data-search-input data-cells="18" /> ]</span>{toggleGap │
│ }<ThemeToggle /></Pre> │
│ <ul class="masthead-search-results" hidden data-search-results></ul> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nts/TopHeader/TopHeader.astro ───┐
│ diff --git a/ooknet-design/src/components/To │
│ pHeader/TopHeader.astro b/ooknet-design/src/ │
│ components/TopHeader/TopHeader.astro │
│ index 0a19b5d..5b5c877 100644 │
│ --- a/ooknet-design/src/components/TopHeader │
│ /TopHeader.astro │
│ +++ b/ooknet-design/src/components/TopHeader │
│ /TopHeader.astro │
│ @@ -19,7 +19,7 @@ const toggleGap = " ".repe │
│ at(MASTHEAD_TOGGLE_GAP); │
│ const PLACEHOLDER_WIDE = "_".repeat(20); │
│ const PLACEHOLDER_NARROW = "_".repeat(18); │
│ --- │
│ -<div class="frame-wide"> │
│ +<div class="frame-wide top-header"> │
│ <div class="masthead"> │
│ <Pre><a href="/" class="masthead-home"> │
│ {MASTHEAD_LABEL}</a>{padW}<span class="masth │
│ ead-search">[ / search the index <input cla │
│ ss="masthead-search-input" type="text" aria- │
│ label="Search notes" autocomplete="off" spel │
│ lcheck="false" placeholder={PLACEHOLDER_WIDE │
│ } maxlength="20" data-search-input data-cell │
│ s="20" /> ]</span>{toggleGap}<ThemeToggle /> │
│ </Pre> │
│ <ul class="masthead-search-results" hid │
│ den data-search-results></ul> │
│ @@ -28,7 +28,7 @@ const PLACEHOLDER_NARROW = │
│ "_".repeat(18); │
│ <NavBar width={FRAME_W} /> │
│ <Pre>{ruleD(FRAME_W)}</Pre> │
│ </div> │
│ -<div class="frame-narrow"> │
│ +<div class="frame-narrow top-header"> │
│ <div class="masthead"> │
│ <Pre><a href="/" class="masthead-home"> │
│ {MASTHEAD_LABEL}</a>{padN}<span class="masth │
│ ead-search">[ / search <input class="masthe │
│ ad-search-input" type="text" aria-label="Sea │
│ rch notes" autocomplete="off" spellcheck="fa │
│ lse" placeholder={PLACEHOLDER_NARROW} maxlen │
│ gth="18" data-search-input data-cells="18" / │
│ > ]</span>{toggleGap}<ThemeToggle /></Pre> │
│ <ul class="masthead-search-results" hid │
│ den data-search-results></ul> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/layouts/ArticleLayout/ArticleLayout.scss ───────────────┐
│ diff --git a/ooknet-design/src/layouts/ArticleLayout/ArticleLayout.scss b/ooknet-d │
│ esign/src/layouts/ArticleLayout/ArticleLayout.scss │
│ index 3f0b305..6976128 100644 │
│ --- a/ooknet-design/src/layouts/ArticleLayout/ArticleLayout.scss │
│ +++ b/ooknet-design/src/layouts/ArticleLayout/ArticleLayout.scss │
│ @@ -30,4 +30,11 @@ │
│ &::before { display: none; } │
│ box-shadow: none; │
│ } │
│ + │
│ + @media print { │
│ + box-shadow: none; │
│ + min-height: auto; │
│ + overflow: visible; │
│ + &::before { display: none; } │
│ + } │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...icleLayout/ArticleLayout.scss ───┐
│ diff --git a/ooknet-design/src/layouts/Artic │
│ leLayout/ArticleLayout.scss b/ooknet-design/ │
│ src/layouts/ArticleLayout/ArticleLayout.scss │
│ index 3f0b305..6976128 100644 │
│ --- a/ooknet-design/src/layouts/ArticleLayou │
│ t/ArticleLayout.scss │
│ +++ b/ooknet-design/src/layouts/ArticleLayou │
│ t/ArticleLayout.scss │
│ @@ -30,4 +30,11 @@ │
│ &::before { display: none; } │
│ box-shadow: none; │
│ } │
│ + │
│ + @media print { │
│ + box-shadow: none; │
│ + min-height: auto; │
│ + overflow: visible; │
│ + &::before { display: none; } │
│ + } │
│ } │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/pages/components.astro ─────────────────────────────────┐
│ diff --git a/ooknet-design/src/pages/components.astro b/ooknet-design/src/pages/co │
│ mponents.astro │
│ index 48cc70d..5b41820 100644 │
│ --- a/ooknet-design/src/pages/components.astro │
│ +++ b/ooknet-design/src/pages/components.astro │
│ @@ -32,6 +32,11 @@ import Pagination from "../components/Pagination/Pagination.ast │
│ ro"; │
│ import Switch from "../components/Switch/Switch.astro"; │
│ import NumberStepper from "../components/NumberStepper/NumberStepper.astro"; │
│ import Dialog from "../components/Dialog/Dialog.astro"; │
│ +import Menu from "../components/Menu/Menu.astro"; │
│ +import Tooltip from "../components/Tooltip/Tooltip.astro"; │
│ +import Slider from "../components/Slider/Slider.astro"; │
│ +import Skeleton from "../components/Skeleton/Skeleton.astro"; │
│ +import Diff from "../components/Diff/Diff.astro"; │
│ import { rule, ruleD, row2, pad } from "../lib/ascii"; │
│ │
│ const section = (title: string, right: string) => (w: number) => │
│ @@ -240,6 +245,37 @@ const FILED_TREE: TreeNode[] = [ │
│ <Breadcrumbs filed="/INFO/GRAPH/ENTROPY/BOUND" /> │
│ <Pre>{" "}</Pre> │
│ │
│ + <Frame build={section("DIFF", "Diff.astro")} /> │
│ + <Diff code={`@@ -1,3 +1,4 @@\n export function pad(s: unknown, n: number): stri │
│ ng {\n- return String(s).padEnd(n);\n+ const str = String(s ?? "");\n+ return s │
│ tr + " ".repeat(Math.max(0, n - len(str)));\n }`} /> │
│ + │
│ + <Frame build={section("MENU", "Menu.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Menu label="ACTIONS" items={[ │
│ + { label: "COPY LINK" }, │
│ + { label: "DOWNLOAD .MD" }, │
│ + { label: "VIEW SOURCE", href: "https://github.com/" }, │
│ + { label: "REPORT DRIFT" }, │
│ + ]} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("TOOLTIP", "Tooltip.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{"frame font "}<Tooltip text="Cascadia Mono - every glyph measured against │
│ the 0 advance before joining the grid" />{" threshold "}<Tooltip text="the gaug │
│ e flips its status word here" label="i" /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("SLIDER", "Slider.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{pad("SQUELCH", LABEL_W)}<Slider name="demo-squelch" value={35} cells={24} │
│ /></Pre> │
│ + <Pre>{pad("GAIN", LABEL_W)}<Slider name="demo-gain" value={70} min={0} max={100 │
│ } step={5} cells={24} /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("SKELETON", "Skeleton.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Skeleton rows={[32, 26, 29, 14]} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ <Frame build={section("SWITCH", "Switch.astro")} /> │
│ <Pre>{" "}</Pre> │
│ <Pre><Switch label="AUTO-ACK INCOMING" name="demo-autoack" checked /></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 48cc70d..5b41820 100644 │
│ --- a/ooknet-design/src/pages/components.ast │
│ ro │
│ +++ b/ooknet-design/src/pages/components.ast │
│ ro │
│ @@ -32,6 +32,11 @@ import Pagination from ". │
│ ./components/Pagination/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 Menu from "../components/Menu/Menu.a │
│ stro"; │
│ +import Tooltip from "../components/Tooltip/ │
│ Tooltip.astro"; │
│ +import Slider from "../components/Slider/Sl │
│ ider.astro"; │
│ +import Skeleton from "../components/Skeleto │
│ n/Skeleton.astro"; │
│ +import Diff from "../components/Diff/Diff.a │
│ stro"; │
│ import { rule, ruleD, row2, pad } from "../ │
│ lib/ascii"; │
│ │
│ const section = (title: string, right: stri │
│ ng) => (w: number) => │
│ @@ -240,6 +245,37 @@ const FILED_TREE: TreeN │
│ ode[] = [ │
│ <Breadcrumbs filed="/INFO/GRAPH/ENTROPY/B │
│ OUND" /> │
│ <Pre>{" "}</Pre> │
│ │
│ + <Frame build={section("DIFF", "Diff.astro │
│ ")} /> │
│ + <Diff code={`@@ -1,3 +1,4 @@\n export fun │
│ ction pad(s: unknown, n: number): string {\n │
│ - return String(s).padEnd(n);\n+ const str │
│ = String(s ?? "");\n+ return str + " ".rep │
│ eat(Math.max(0, n - len(str)));\n }`} /> │
│ + │
│ + <Frame build={section("MENU", "Menu.astro │
│ ")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Menu label="ACTIONS" items={[ │
│ + { label: "COPY LINK" }, │
│ + { label: "DOWNLOAD .MD" }, │
│ + { label: "VIEW SOURCE", href: "https:// │
│ github.com/" }, │
│ + { label: "REPORT DRIFT" }, │
│ + ]} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("TOOLTIP", "Tooltip │
│ .astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{"frame font "}<Tooltip text="Cascad │
│ ia Mono - every glyph measured against the 0 │
│ advance before joining the grid" />{" thr │
│ eshold "}<Tooltip text="the gauge flips its │
│ status word here" label="i" /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("SLIDER", "Slider.a │
│ stro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{pad("SQUELCH", LABEL_W)}<Slider nam │
│ e="demo-squelch" value={35} cells={24} /></P │
│ re> │
│ + <Pre>{pad("GAIN", LABEL_W)}<Slider name=" │
│ demo-gain" value={70} min={0} max={100} step │
│ ={5} cells={24} /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("SKELETON", "Skelet │
│ on.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Skeleton rows={[32, 26, 29, 14]} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ <Frame build={section("SWITCH", "Switch.a │
│ stro")} /> │
│ <Pre>{" "}</Pre> │
│ <Pre><Switch label="AUTO-ACK INCOMING" na │
│ me="demo-autoack" checked /></Pre> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/pages/status.astro ─────────────────────────────────────┐
│ diff --git a/ooknet-design/src/pages/status.astro b/ooknet-design/src/pages/status │
│ .astro │
│ new file mode 100644 │
│ index 0000000..ccf2afd │
│ --- /dev/null │
│ +++ b/ooknet-design/src/pages/status.astro │
│ @@ -0,0 +1,83 @@ │
│ +--- │
│ +// System telemetry - dogfoods the display components with real numbers │
│ +// computed from the notes collection at build time. │
│ +import { getCollection } from "astro:content"; │
│ +import ArticleLayout from "../layouts/ArticleLayout/ArticleLayout.astro"; │
│ +import TopHeader from "../components/TopHeader/TopHeader.astro"; │
│ +import Frame from "../components/Frame/Frame.astro"; │
│ +import Pre from "../components/Pre/Pre.astro"; │
│ +import KeyValue from "../components/KeyValue/KeyValue.astro"; │
│ +import Gauge from "../components/Gauge/Gauge.astro"; │
│ +import Sparkline from "../components/Sparkline/Sparkline.astro"; │
│ +import Calendar from "../components/Calendar/Calendar.astro"; │
│ +import Timeline from "../components/Timeline/Timeline.astro"; │
│ +import type { TimelineEntry } from "../components/Timeline/Timeline"; │
│ +import Steps from "../components/Steps/Steps.astro"; │
│ +import { rule, ruleD, row2, pad } from "../lib/ascii"; │
│ +import { published, byNoteNum } from "../lib/notes"; │
│ + │
│ +const section = (title: string, right: string) => (w: number) => │
│ + [row2(title, right, w), ruleD(w)].join("\n"); │
│ +const LABEL_W = 12; │
│ + │
│ +const notes = (await getCollection("notes", published)).sort(byNoteNum); │
│ +const words = notes.map((n) => (n.body ?? "").split(/\s+/).filter(Boolean).length │
│ ); │
│ +const totalWords = words.reduce((a, b) => a + b, 0); │
│ +const tagCount = new Set(notes.flatMap((n) => n.data.meta.tags)).size; │
│ + │
│ +const byDate = [...notes].sort((a, b) => (a.data.published < b.data.published ? 1 │
│ : -1)); │
│ +const latestDate = byDate[0].data.published; │
│ +const [year, month] = latestDate.split("-").map(Number); │
│ +const monthPrefix = `${year}-${String(month).padStart(2, "0")}`; │
│ +const marks = byDate │
│ + .filter((n) => n.data.published.startsWith(monthPrefix)) │
│ + .map((n) => Number(n.data.published.slice(8))); │
│ + │
│ +// VOLUME 00 binds at 50 notes; the gauge tracks fill. │
│ +const VOLUME_TARGET = 50; │
│ +const capacity = (notes.length / VOLUME_TARGET) * 100; │
│ + │
│ +const log: TimelineEntry[] = byDate.map((n) => ({ │
│ + date: n.data.published, │
│ + title: `${n.data.note} FILED`, │
│ + desc: n.data.title.toLowerCase(), │
│ + href: `/notes/${n.id}/`, │
│ +})); │
│ +--- │
│ +<ArticleLayout title="OOKNET KB - Status"> │
│ + <TopHeader /> │
│ + <Frame build={(w) => [row2("SYSTEM TELEMETRY", `AS OF ${latestDate}`, w), ruleD │
│ (w)].join("\n")} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <KeyValue entries={[ │
│ + ["NOTES", `${notes.length} FILED`], │
│ + ["WORDS", `${totalWords} TOTAL / ${Math.round(totalWords / notes.length)} AVG │
│ `], │
│ + ["TAGS", `${tagCount} DISTINCT`], │
│ + ["LAST FILING", latestDate], │
│ + ]} keyW={LABEL_W + 2} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("VOLUME 00", "bind at 50 notes")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{pad("CAPACITY", LABEL_W)}<Gauge value={capacity} threshold={80} low="ACCU │
│ MULATING" high="BIND SOON" /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + <Steps steps={["OPEN VOLUME", "ACCUMULATE", "BIND & ARCHIVE"]} current={1} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("SIGNAL", "words per note, by note number")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{pad("WORDS/NOTE", LABEL_W)}<Sparkline values={words} width={40} />{` ${M │
│ ath.max(...words)} PEAK`}</Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("FILINGS", `${monthPrefix} marked`)} /> │
│ + <Pre>{" "}</Pre> │
│ + <Calendar year={year} month={month} marks={marks} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("FILING LOG", "most recent first")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Timeline entries={log} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={(w) => rule(w)} /> │
│ +</ArticleLayout> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...design/src/pages/status.astro ───┐
│ diff --git a/ooknet-design/src/pages/status. │
│ astro b/ooknet-design/src/pages/status.astro │
│ new file mode 100644 │
│ index 0000000..ccf2afd │
│ --- /dev/null │
│ +++ b/ooknet-design/src/pages/status.astro │
│ @@ -0,0 +1,83 @@ │
│ +--- │
│ +// System telemetry - dogfoods the display │
│ components with real numbers │
│ +// computed from the notes collection at bu │
│ ild time. │
│ +import { getCollection } from "astro:conten │
│ t"; │
│ +import ArticleLayout from "../layouts/Artic │
│ leLayout/ArticleLayout.astro"; │
│ +import TopHeader from "../components/TopHea │
│ der/TopHeader.astro"; │
│ +import Frame from "../components/Frame/Fram │
│ e.astro"; │
│ +import Pre from "../components/Pre/Pre.astr │
│ o"; │
│ +import KeyValue from "../components/KeyValu │
│ e/KeyValue.astro"; │
│ +import Gauge from "../components/Gauge/Gaug │
│ e.astro"; │
│ +import Sparkline from "../components/Sparkl │
│ ine/Sparkline.astro"; │
│ +import Calendar from "../components/Calenda │
│ r/Calendar.astro"; │
│ +import Timeline from "../components/Timelin │
│ e/Timeline.astro"; │
│ +import type { TimelineEntry } from "../comp │
│ onents/Timeline/Timeline"; │
│ +import Steps from "../components/Steps/Step │
│ s.astro"; │
│ +import { rule, ruleD, row2, pad } from "../ │
│ lib/ascii"; │
│ +import { published, byNoteNum } from "../li │
│ b/notes"; │
│ + │
│ +const section = (title: string, right: stri │
│ ng) => (w: number) => │
│ + [row2(title, right, w), ruleD(w)].join("\ │
│ n"); │
│ +const LABEL_W = 12; │
│ + │
│ +const notes = (await getCollection("notes", │
│ published)).sort(byNoteNum); │
│ +const words = notes.map((n) => (n.body ?? " │
│ ").split(/\s+/).filter(Boolean).length); │
│ +const totalWords = words.reduce((a, b) => a │
│ + b, 0); │
│ +const tagCount = new Set(notes.flatMap((n) │
│ => n.data.meta.tags)).size; │
│ + │
│ +const byDate = [...notes].sort((a, b) => (a │
│ .data.published < b.data.published ? 1 : -1) │
│ ); │
│ +const latestDate = byDate[0].data.published │
│ ; │
│ +const [year, month] = latestDate.split("-") │
│ .map(Number); │
│ +const monthPrefix = `${year}-${String(month │
│ ).padStart(2, "0")}`; │
│ +const marks = byDate │
│ + .filter((n) => n.data.published.startsWit │
│ h(monthPrefix)) │
│ + .map((n) => Number(n.data.published.slice │
│ (8))); │
│ + │
│ +// VOLUME 00 binds at 50 notes; the gauge t │
│ racks fill. │
│ +const VOLUME_TARGET = 50; │
│ +const capacity = (notes.length / VOLUME_TAR │
│ GET) * 100; │
│ + │
│ +const log: TimelineEntry[] = byDate.map((n) │
│ => ({ │
│ + date: n.data.published, │
│ + title: `${n.data.note} FILED`, │
│ + desc: n.data.title.toLowerCase(), │
│ + href: `/notes/${n.id}/`, │
│ +})); │
│ +--- │
│ +<ArticleLayout title="OOKNET KB - Status"> │
│ + <TopHeader /> │
│ + <Frame build={(w) => [row2("SYSTEM TELEME │
│ TRY", `AS OF ${latestDate}`, w), ruleD(w)].j │
│ oin("\n")} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <KeyValue entries={[ │
│ + ["NOTES", `${notes.length} FILED`], │
│ + ["WORDS", `${totalWords} TOTAL / ${Math │
│ .round(totalWords / notes.length)} AVG`], │
│ + ["TAGS", `${tagCount} DISTINCT`], │
│ + ["LAST FILING", latestDate], │
│ + ]} keyW={LABEL_W + 2} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("VOLUME 00", "bind │
│ at 50 notes")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{pad("CAPACITY", LABEL_W)}<Gauge val │
│ ue={capacity} threshold={80} low="ACCUMULATI │
│ NG" high="BIND SOON" /></Pre> │
│ + <Pre>{" "}</Pre> │
│ + <Steps steps={["OPEN VOLUME", "ACCUMULATE │
│ ", "BIND & ARCHIVE"]} current={1} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("SIGNAL", "words pe │
│ r note, by note number")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Pre>{pad("WORDS/NOTE", LABEL_W)}<Sparkli │
│ ne values={words} width={40} />{` ${Math.ma │
│ x(...words)} PEAK`}</Pre> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("FILINGS", `${month │
│ Prefix} marked`)} /> │
│ + <Pre>{" "}</Pre> │
│ + <Calendar year={year} month={month} marks │
│ ={marks} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("FILING LOG", "most │
│ recent first")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Timeline entries={log} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={(w) => rule(w)} /> │
│ +</ArticleLayout> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/styles/global.scss ─────────────────────────────────────┐
│ diff --git a/ooknet-design/src/styles/global.scss b/ooknet-design/src/styles/globa │
│ l.scss │
│ index f7d4677..33a5fc8 100644 │
│ --- a/ooknet-design/src/styles/global.scss │
│ +++ b/ooknet-design/src/styles/global.scss │
│ @@ -109,3 +109,49 @@ body { │
│ display: flex; │
│ justify-content: center; │
│ } │
│ + │
│ +// Print: ink on paper, literally. Force the light palette and the wide │
│ +// frame, drop the screen chrome (nav, search, punch column, toasts), │
│ +// and keep frames from splitting across page breaks. │
│ +@media print { │
│ + :root, │
│ + html[data-theme="dark"] { │
│ + --paper: #fff; │
│ + --ink: #000; │
│ + --ink-soft: #444; │
│ + --backdrop: #fff; │
│ + --fs: 11px; │
│ + --frame-cells: 86; │
│ + --page-pad-t: 0; │
│ + --page-pad-r: 0; │
│ + --page-pad-b: 0; │
│ + --page-pad-l: 0; │
│ + --page-w: auto; │
│ + } │
│ + │
│ + body { │
│ + display: block; │
│ + background: #fff; │
│ + } │
│ + │
│ + .frame-narrow { │
│ + display: none !important; │
│ + } │
│ + .frame-wide { │
│ + display: revert !important; │
│ + } │
│ + │
│ + .top-header, │
│ + .article-pagination, │
│ + .toast-region { │
│ + display: none !important; │
│ + } │
│ + │
│ + pre.ascii, │
│ + .figure-frame, │
│ + .table-frames, │
│ + .code-frames, │
│ + blockquote.callout { │
│ + break-inside: avoid; │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...design/src/styles/global.scss ───┐
│ diff --git a/ooknet-design/src/styles/global │
│ .scss b/ooknet-design/src/styles/global.scss │
│ index f7d4677..33a5fc8 100644 │
│ --- a/ooknet-design/src/styles/global.scss │
│ +++ b/ooknet-design/src/styles/global.scss │
│ @@ -109,3 +109,49 @@ body { │
│ display: flex; │
│ justify-content: center; │
│ } │
│ + │
│ +// Print: ink on paper, literally. Force th │
│ e light palette and the wide │
│ +// frame, drop the screen chrome (nav, sear │
│ ch, punch column, toasts), │
│ +// and keep frames from splitting across pa │
│ ge breaks. │
│ +@media print { │
│ + :root, │
│ + html[data-theme="dark"] { │
│ + --paper: #fff; │
│ + --ink: #000; │
│ + --ink-soft: #444; │
│ + --backdrop: #fff; │
│ + --fs: 11px; │
│ + --frame-cells: 86; │
│ + --page-pad-t: 0; │
│ + --page-pad-r: 0; │
│ + --page-pad-b: 0; │
│ + --page-pad-l: 0; │
│ + --page-w: auto; │
│ + } │
│ + │
│ + body { │
│ + display: block; │
│ + background: #fff; │
│ + } │
│ + │
│ + .frame-narrow { │
│ + display: none !important; │
│ + } │
│ + .frame-wide { │
│ + display: revert !important; │
│ + } │
│ + │
│ + .top-header, │
│ + .article-pagination, │
│ + .toast-region { │
│ + display: none !important; │
│ + } │
│ + │
│ + pre.ascii, │
│ + .figure-frame, │
│ + .table-frames, │
│ + .code-frames, │
│ + blockquote.callout { │
│ + break-inside: avoid; │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET