HASH c87d79a27c7c
DATE 2026-07-17
SUBJECT web: toc, accordion, timeline, toast, and diff fences
FILES 22 CHANGED
HASH c87d79a27c7c
DATE 2026-07-17
SUBJECT web: toc, accordion, timeline, toast,
and diff fences
FILES 22 CHANGED
┌─ ooknet-design/src/components/Accordion/Accordion.astro ───────────────────┐
│ diff --git a/ooknet-design/src/components/Accordion/Accordion.astro b/ooknet-desig │
│ n/src/components/Accordion/Accordion.astro │
│ new file mode 100644 │
│ index 0000000..79bf6b7 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Accordion/Accordion.astro │
│ @@ -0,0 +1,13 @@ │
│ +--- │
│ +// Native <details> with ASCII chrome. Renders once - the [+] marker │
│ +// and left rail are width-independent, so no dual-render is needed and │
│ +// open state survives viewport changes. │
│ +import type { Props } from "./Accordion"; │
│ +import "./Accordion.scss"; │
│ + │
│ +const { label, open = false } = Astro.props as Props; │
│ +--- │
│ +<details class="accordion" open={open}> │
│ + <summary class="acc-summary">{label}</summary> │
│ + <div class="acc-body"><slot /></div> │
│ +</details> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nts/Accordion/Accordion.astro ───┐
│ diff --git a/ooknet-design/src/components/Ac │
│ cordion/Accordion.astro b/ooknet-design/src/ │
│ components/Accordion/Accordion.astro │
│ new file mode 100644 │
│ index 0000000..79bf6b7 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Accordion │
│ /Accordion.astro │
│ @@ -0,0 +1,13 @@ │
│ +--- │
│ +// Native <details> with ASCII chrome. Rend │
│ ers once - the [+] marker │
│ +// and left rail are width-independent, so │
│ no dual-render is needed and │
│ +// open state survives viewport changes. │
│ +import type { Props } from "./Accordion"; │
│ +import "./Accordion.scss"; │
│ + │
│ +const { label, open = false } = Astro.props │
│ as Props; │
│ +--- │
│ +<details class="accordion" open={open}> │
│ + <summary class="acc-summary">{label}</sum │
│ mary> │
│ + <div class="acc-body"><slot /></div> │
│ +</details> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Accordion/Accordion.scss ────────────────────┐
│ diff --git a/ooknet-design/src/components/Accordion/Accordion.scss b/ooknet-design │
│ /src/components/Accordion/Accordion.scss │
│ new file mode 100644 │
│ index 0000000..04c1858 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Accordion/Accordion.scss │
│ @@ -0,0 +1,65 @@ │
│ +.accordion { │
│ + font-family: var(--mono); │
│ + font-size: var(--fs); │
│ + line-height: var(--lh); │
│ + max-width: var(--frame-w); │
│ + margin: 0.4em 0; │
│ + │
│ + .acc-summary { │
│ + cursor: pointer; │
│ + list-style: none; │
│ + font-weight: 700; │
│ + text-transform: uppercase; │
│ + white-space: pre; │
│ + │
│ + &::-webkit-details-marker { │
│ + display: none; │
│ + } │
│ + │
│ + &::before { │
│ + content: "[+] "; │
│ + font-family: var(--mono-frame); │
│ + font-weight: 400; │
│ + } │
│ + │
│ + &:hover { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + } │
│ + │
│ + &[open] > .acc-summary::before { │
│ + content: "[-] "; │
│ + } │
│ + │
│ + .acc-body { │
│ + position: relative; │
│ + margin: 0.4em 0 0.8em; │
│ + padding-left: 4ch; │
│ + │
│ + // Same glyph rail as blockquotes, seated under the marker column. │
│ + &::before { │
│ + 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; │
│ + left: 1ch; │
│ + top: 0; │
│ + bottom: 0; │
│ + font-family: var(--mono-frame); │
│ + color: var(--ink); │
│ + white-space: pre; │
│ + overflow: hidden; │
│ + } │
│ + │
│ + p { │
│ + margin: 0 0 0.6em; │
│ + } │
│ + p:last-child { │
│ + margin-bottom: 0; │
│ + } │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ents/Accordion/Accordion.scss ───┐
│ diff --git a/ooknet-design/src/components/Ac │
│ cordion/Accordion.scss b/ooknet-design/src/c │
│ omponents/Accordion/Accordion.scss │
│ new file mode 100644 │
│ index 0000000..04c1858 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Accordion │
│ /Accordion.scss │
│ @@ -0,0 +1,65 @@ │
│ +.accordion { │
│ + font-family: var(--mono); │
│ + font-size: var(--fs); │
│ + line-height: var(--lh); │
│ + max-width: var(--frame-w); │
│ + margin: 0.4em 0; │
│ + │
│ + .acc-summary { │
│ + cursor: pointer; │
│ + list-style: none; │
│ + font-weight: 700; │
│ + text-transform: uppercase; │
│ + white-space: pre; │
│ + │
│ + &::-webkit-details-marker { │
│ + display: none; │
│ + } │
│ + │
│ + &::before { │
│ + content: "[+] "; │
│ + font-family: var(--mono-frame); │
│ + font-weight: 400; │
│ + } │
│ + │
│ + &:hover { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + } │
│ + │
│ + &[open] > .acc-summary::before { │
│ + content: "[-] "; │
│ + } │
│ + │
│ + .acc-body { │
│ + position: relative; │
│ + margin: 0.4em 0 0.8em; │
│ + padding-left: 4ch; │
│ + │
│ + // Same glyph rail as blockquotes, seat │
│ ed under the marker column. │
│ + &::before { │
│ + 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; │
│ + left: 1ch; │
│ + top: 0; │
│ + bottom: 0; │
│ + font-family: var(--mono-frame); │
│ + color: var(--ink); │
│ + white-space: pre; │
│ + overflow: hidden; │
│ + } │
│ + │
│ + p { │
│ + margin: 0 0 0.6em; │
│ + } │
│ + p:last-child { │
│ + margin-bottom: 0; │
│ + } │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Accordion/Accordion.ts ──────────────────────┐
│ diff --git a/ooknet-design/src/components/Accordion/Accordion.ts b/ooknet-design/s │
│ rc/components/Accordion/Accordion.ts │
│ new file mode 100644 │
│ index 0000000..bfc62b2 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Accordion/Accordion.ts │
│ @@ -0,0 +1,4 @@ │
│ +export interface Props { │
│ + label: string; │
│ + open?: boolean; │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...onents/Accordion/Accordion.ts ───┐
│ diff --git a/ooknet-design/src/components/Ac │
│ cordion/Accordion.ts b/ooknet-design/src/com │
│ ponents/Accordion/Accordion.ts │
│ new file mode 100644 │
│ index 0000000..bfc62b2 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Accordion │
│ /Accordion.ts │
│ @@ -0,0 +1,4 @@ │
│ +export interface Props { │
│ + label: string; │
│ + open?: boolean; │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Prose/Prose.scss ────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Prose/Prose.scss b/ooknet-design/src/com │
│ ponents/Prose/Prose.scss │
│ index f2efa34..7319592 100644 │
│ --- a/ooknet-design/src/components/Prose/Prose.scss │
│ +++ b/ooknet-design/src/components/Prose/Prose.scss │
│ @@ -161,6 +161,11 @@ │
│ pointer-events: none; │
│ } │
│ │
│ + // ```diff fences - monochrome: adds bold, everything else soft. │
│ + .diff-add { font-weight: 700; } │
│ + .diff-del { color: var(--ink-soft); } │
│ + .diff-hunk, .diff-meta { color: var(--ink-soft); font-style: italic; } │
│ + │
│ .code-copy { │
│ font: inherit; │
│ color: var(--ink-soft); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/components/Prose/Prose.scss ───┐
│ diff --git a/ooknet-design/src/components/Pr │
│ ose/Prose.scss b/ooknet-design/src/component │
│ s/Prose/Prose.scss │
│ index f2efa34..7319592 100644 │
│ --- a/ooknet-design/src/components/Prose/Pro │
│ se.scss │
│ +++ b/ooknet-design/src/components/Prose/Pro │
│ se.scss │
│ @@ -161,6 +161,11 @@ │
│ pointer-events: none; │
│ } │
│ │
│ + // ```diff fences - monochrome: adds bo │
│ ld, everything else soft. │
│ + .diff-add { font-weight: 700; } │
│ + .diff-del { color: var(--ink-soft); } │
│ + .diff-hunk, .diff-meta { color: var(--i │
│ nk-soft); font-style: italic; } │
│ + │
│ .code-copy { │
│ font: inherit; │
│ color: var(--ink-soft); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/TableOfContents/TableOfContents.astro ───────┐
│ diff --git a/ooknet-design/src/components/TableOfContents/TableOfContents.astro b/ │
│ ooknet-design/src/components/TableOfContents/TableOfContents.astro │
│ new file mode 100644 │
│ index 0000000..3df71c2 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/TableOfContents/TableOfContents.astro │
│ @@ -0,0 +1,25 @@ │
│ +--- │
│ +// Dual-rendered contents block. Rows carry links, so they render as │
│ +// per-row Pres (like Tree) rather than through Frame. │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import Frame from "../Frame/Frame.astro"; │
│ +import { rule, row2 } from "../../lib/ascii"; │
│ +import { FRAME_W, MOBILE_FRAME_W } from "../../lib/config"; │
│ +import { layoutToc, type Props, type TocRow } from "./TableOfContents"; │
│ +import "./TableOfContents.scss"; │
│ + │
│ +const { headings, maxDepth = 3 } = Astro.props as Props; │
│ +const wide = layoutToc(headings, FRAME_W, maxDepth); │
│ +const narrow = layoutToc(headings, MOBILE_FRAME_W, maxDepth); │
│ + │
│ +const row = (r: TocRow, variant: string) => ({ r, variant }); │
│ +const rows = [...wide.map((r) => row(r, "frame-wide")), ...narrow.map((r) => row( │
│ r, "frame-narrow"))]; │
│ +--- │
│ +{wide.length > 0 && ( │
│ + <nav class="toc" aria-label="Contents"> │
│ + <Frame build={(w) => [row2("CONTENTS", `${wide.length} SECTIONS`, w), rule(w) │
│ ].join("\n")} /> │
│ + {rows.map(({ r, variant }) => ( │
│ + <Pre class={`${variant} toc-row`}>{r.indent}<a class="toc-link" href={`#${r │
│ .slug}`}>{r.title}</a><span class="toc-tail" aria-hidden="true">{` ${r.leader} ${r │
│ .marker}`}</span></Pre> │
│ + ))} │
│ + </nav> │
│ +)} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ontents/TableOfContents.astro ───┐
│ diff --git a/ooknet-design/src/components/Ta │
│ bleOfContents/TableOfContents.astro b/ooknet │
│ -design/src/components/TableOfContents/Table │
│ OfContents.astro │
│ new file mode 100644 │
│ index 0000000..3df71c2 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/TableOfCo │
│ ntents/TableOfContents.astro │
│ @@ -0,0 +1,25 @@ │
│ +--- │
│ +// Dual-rendered contents block. Rows carry │
│ links, so they render as │
│ +// per-row Pres (like Tree) rather than thr │
│ ough Frame. │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import Frame from "../Frame/Frame.astro"; │
│ +import { rule, row2 } from "../../lib/ascii │
│ "; │
│ +import { FRAME_W, MOBILE_FRAME_W } from ".. │
│ /../lib/config"; │
│ +import { layoutToc, type Props, type TocRow │
│ } from "./TableOfContents"; │
│ +import "./TableOfContents.scss"; │
│ + │
│ +const { headings, maxDepth = 3 } = Astro.pr │
│ ops as Props; │
│ +const wide = layoutToc(headings, FRAME_W, m │
│ axDepth); │
│ +const narrow = layoutToc(headings, MOBILE_F │
│ RAME_W, maxDepth); │
│ + │
│ +const row = (r: TocRow, variant: string) => │
│ ({ r, variant }); │
│ +const rows = [...wide.map((r) => row(r, "fr │
│ ame-wide")), ...narrow.map((r) => row(r, "fr │
│ ame-narrow"))]; │
│ +--- │
│ +{wide.length > 0 && ( │
│ + <nav class="toc" aria-label="Contents"> │
│ + <Frame build={(w) => [row2("CONTENTS", │
│ `${wide.length} SECTIONS`, w), rule(w)].join │
│ ("\n")} /> │
│ + {rows.map(({ r, variant }) => ( │
│ + <Pre class={`${variant} toc-row`}>{r. │
│ indent}<a class="toc-link" href={`#${r.slug} │
│ `}>{r.title}</a><span class="toc-tail" aria- │
│ hidden="true">{` ${r.leader} ${r.marker}`}</ │
│ span></Pre> │
│ + ))} │
│ + </nav> │
│ +)} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/TableOfContents/TableOfContents.scss ────────┐
│ diff --git a/ooknet-design/src/components/TableOfContents/TableOfContents.scss b/o │
│ oknet-design/src/components/TableOfContents/TableOfContents.scss │
│ new file mode 100644 │
│ index 0000000..d72e77d │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/TableOfContents/TableOfContents.scss │
│ @@ -0,0 +1,23 @@ │
│ +.toc { │
│ + display: block; │
│ + margin: 1.2em 0; │
│ +} │
│ + │
│ +.toc-link { │
│ + color: var(--ink); │
│ + text-decoration: none; │
│ + │
│ + &:hover { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ +} │
│ + │
│ +.toc-tail { │
│ + color: var(--ink-soft); │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...Contents/TableOfContents.scss ───┐
│ diff --git a/ooknet-design/src/components/Ta │
│ bleOfContents/TableOfContents.scss b/ooknet- │
│ design/src/components/TableOfContents/TableO │
│ fContents.scss │
│ new file mode 100644 │
│ index 0000000..d72e77d │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/TableOfCo │
│ ntents/TableOfContents.scss │
│ @@ -0,0 +1,23 @@ │
│ +.toc { │
│ + display: block; │
│ + margin: 1.2em 0; │
│ +} │
│ + │
│ +.toc-link { │
│ + color: var(--ink); │
│ + text-decoration: none; │
│ + │
│ + &:hover { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ +} │
│ + │
│ +.toc-tail { │
│ + color: var(--ink-soft); │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/TableOfContents/TableOfContents.test.ts ─────┐
│ diff --git a/ooknet-design/src/components/TableOfContents/TableOfContents.test.ts │
│ b/ooknet-design/src/components/TableOfContents/TableOfContents.test.ts │
│ new file mode 100644 │
│ index 0000000..5a71fc7 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/TableOfContents/TableOfContents.test.ts │
│ @@ -0,0 +1,47 @@ │
│ +import { describe, expect, it } from "vitest"; │
│ +import { layoutToc } from "./TableOfContents"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +const H = (depth: number, slug: string, text: string) => ({ depth, slug, text }); │
│ +const line = (r: ReturnType<typeof layoutToc>[number]) => │
│ + `${r.indent}${r.title} ${r.leader} ${r.marker}`; │
│ + │
│ +describe("layoutToc", () => { │
│ + it("numbers sections hierarchically", () => { │
│ + const rows = layoutToc([H(1, "a", "A"), H(2, "b", "B"), H(2, "c", "C"), H(1, │
│ "d", "D")], 48); │
│ + expect(rows.map((r) => r.marker)).toEqual(["?1", "?1.1", "?1.2", "?2"]); │
│ + }); │
│ + │
│ + it("fills the exact width at any frame size", () => { │
│ + for (const w of [48, 86]) { │
│ + const rows = layoutToc([H(1, "a", "Alpha"), H(2, "b", "Beta section")], w); │
│ + for (const r of rows) expect(len(line(r))).toBe(w); │
│ + } │
│ + }); │
│ + │
│ + it("normalizes indentation to the shallowest heading", () => { │
│ + const rows = layoutToc([H(2, "a", "A"), H(3, "b", "B")], 48); │
│ + expect(rows[0].indent).toBe(""); │
│ + expect(rows[1].indent).toBe(" "); │
│ + }); │
│ + │
│ + it("uppercases and collapses whitespace in titles", () => { │
│ + const rows = layoutToc([H(1, "a", "wide tables")], 48); │
│ + expect(rows[0].title).toBe("WIDE TABLES"); │
│ + }); │
│ + │
│ + it("strips head-rule glyphs leaked into extracted heading text", () => { │
│ + const rows = layoutToc([H(1, "a", `Purpose${"─".repeat(86)}`)], 48); │
│ + expect(rows[0].title).toBe("PURPOSE"); │
│ + }); │
│ + │
│ + it("truncates long titles with an ellipsis and still fits", () => { │
│ + const rows = layoutToc([H(1, "a", "x".repeat(60))], 48); │
│ + expect(rows[0].title.endsWith("...")).toBe(true); │
│ + expect(len(line(rows[0]))).toBe(48); │
│ + }); │
│ + │
│ + it("drops headings deeper than maxDepth", () => { │
│ + expect(layoutToc([H(1, "a", "A"), H(4, "b", "B")], 48)).toHaveLength(1); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...tents/TableOfContents.test.ts ───┐
│ diff --git a/ooknet-design/src/components/Ta │
│ bleOfContents/TableOfContents.test.ts b/ookn │
│ et-design/src/components/TableOfContents/Tab │
│ leOfContents.test.ts │
│ new file mode 100644 │
│ index 0000000..5a71fc7 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/TableOfCo │
│ ntents/TableOfContents.test.ts │
│ @@ -0,0 +1,47 @@ │
│ +import { describe, expect, it } from "vites │
│ t"; │
│ +import { layoutToc } from "./TableOfContent │
│ s"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +const H = (depth: number, slug: string, tex │
│ t: string) => ({ depth, slug, text }); │
│ +const line = (r: ReturnType<typeof layoutTo │
│ c>[number]) => │
│ + `${r.indent}${r.title} ${r.leader} ${r.ma │
│ rker}`; │
│ + │
│ +describe("layoutToc", () => { │
│ + it("numbers sections hierarchically", () │
│ => { │
│ + const rows = layoutToc([H(1, "a", "A"), │
│ H(2, "b", "B"), H(2, "c", "C"), H(1, "d", " │
│ D")], 48); │
│ + expect(rows.map((r) => r.marker)).toEqu │
│ al(["?1", "?1.1", "?1.2", "?2"]); │
│ + }); │
│ + │
│ + it("fills the exact width at any frame si │
│ ze", () => { │
│ + for (const w of [48, 86]) { │
│ + const rows = layoutToc([H(1, "a", "Al │
│ pha"), H(2, "b", "Beta section")], w); │
│ + for (const r of rows) expect(len(line │
│ (r))).toBe(w); │
│ + } │
│ + }); │
│ + │
│ + it("normalizes indentation to the shallow │
│ est heading", () => { │
│ + const rows = layoutToc([H(2, "a", "A"), │
│ H(3, "b", "B")], 48); │
│ + expect(rows[0].indent).toBe(""); │
│ + expect(rows[1].indent).toBe(" "); │
│ + }); │
│ + │
│ + it("uppercases and collapses whitespace i │
│ n titles", () => { │
│ + const rows = layoutToc([H(1, "a", "wide │
│ tables")], 48); │
│ + expect(rows[0].title).toBe("WIDE TABLES │
│ "); │
│ + }); │
│ + │
│ + it("strips head-rule glyphs leaked into e │
│ xtracted heading text", () => { │
│ + const rows = layoutToc([H(1, "a", `Purp │
│ ose${"─".repeat(86)}`)], 48); │
│ + expect(rows[0].title).toBe("PURPOSE"); │
│ + }); │
│ + │
│ + it("truncates long titles with an ellipsi │
│ s and still fits", () => { │
│ + const rows = layoutToc([H(1, "a", "x".r │
│ epeat(60))], 48); │
│ + expect(rows[0].title.endsWith("...")).t │
│ oBe(true); │
│ + expect(len(line(rows[0]))).toBe(48); │
│ + }); │
│ + │
│ + it("drops headings deeper than maxDepth", │
│ () => { │
│ + expect(layoutToc([H(1, "a", "A"), H(4, │
│ "b", "B")], 48)).toHaveLength(1); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/TableOfContents/TableOfContents.ts ──────────┐
│ diff --git a/ooknet-design/src/components/TableOfContents/TableOfContents.ts b/ook │
│ net-design/src/components/TableOfContents/TableOfContents.ts │
│ new file mode 100644 │
│ index 0000000..d279f1e │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/TableOfContents/TableOfContents.ts │
│ @@ -0,0 +1,53 @@ │
│ +// Pure TOC layout: markdown headings ? numbered rows with dot leaders. │
│ +// Returns parts (indent / title / leader / marker) so the component can │
│ +// wrap the title in a link. │
│ +import { checkGrid, len } from "../../lib/ascii"; │
│ + │
│ +export interface TocHeading { │
│ + depth: number; │
│ + slug: string; │
│ + text: string; │
│ +} │
│ + │
│ +export interface TocRow { │
│ + indent: string; │
│ + title: string; │
│ + /** Dot leader between title and marker. */ │
│ + leader: string; │
│ + /** ?-style section number. */ │
│ + marker: string; │
│ + slug: string; │
│ +} │
│ + │
│ +export interface Props { │
│ + headings: TocHeading[]; │
│ + maxDepth?: number; │
│ +} │
│ + │
│ +const MIN_DOTS = 3; │
│ + │
│ +export function layoutToc(headings: TocHeading[], width: number, maxDepth = 3): T │
│ ocRow[] { │
│ + const hs = headings.filter((h) => h.depth <= maxDepth); │
│ + if (!hs.length) return []; │
│ + │
│ + const min = Math.min(...hs.map((h) => h.depth)); │
│ + const counters: number[] = []; │
│ + │
│ + return hs.map((h) => { │
│ + const level = h.depth - min; │
│ + counters.splice(level + 1); │
│ + counters[level] = (counters[level] ?? 0) + 1; │
│ + const marker = `?${counters.slice(0, level + 1).map((c) => c ?? 1).join(".")} │
│ `; │
│ + │
│ + const indent = " ".repeat(level); │
│ + // Astro extracts heading text after rehype-ascii has appended the │
│ + // h1 head-rule, so box-drawing runs must be stripped back out. │
│ + let title = h.text.replace(/[─-▟]+/gu, "").replace(/\s+/g, " ").trim().toUppe │
│ rCase(); │
│ + const maxTitle = width - len(indent) - len(marker) - 2 - MIN_DOTS; │
│ + if (len(title) > maxTitle) title = [...title].slice(0, maxTitle - 1).join("") │
│ + "..."; │
│ + │
│ + const leader = ".".repeat(width - len(indent) - len(title) - len(marker) - 2) │
│ ; │
│ + checkGrid(`${indent}${title} ${leader} ${marker}`, width, "toc row"); │
│ + return { indent, title, leader, marker, slug: h.slug }; │
│ + }); │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...OfContents/TableOfContents.ts ───┐
│ diff --git a/ooknet-design/src/components/Ta │
│ bleOfContents/TableOfContents.ts b/ooknet-de │
│ sign/src/components/TableOfContents/TableOfC │
│ ontents.ts │
│ new file mode 100644 │
│ index 0000000..d279f1e │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/TableOfCo │
│ ntents/TableOfContents.ts │
│ @@ -0,0 +1,53 @@ │
│ +// Pure TOC layout: markdown headings ? num │
│ bered rows with dot leaders. │
│ +// Returns parts (indent / title / leader / │
│ marker) so the component can │
│ +// wrap the title in a link. │
│ +import { checkGrid, len } from "../../lib/a │
│ scii"; │
│ + │
│ +export interface TocHeading { │
│ + depth: number; │
│ + slug: string; │
│ + text: string; │
│ +} │
│ + │
│ +export interface TocRow { │
│ + indent: string; │
│ + title: string; │
│ + /** Dot leader between title and marker. │
│ */ │
│ + leader: string; │
│ + /** ?-style section number. */ │
│ + marker: string; │
│ + slug: string; │
│ +} │
│ + │
│ +export interface Props { │
│ + headings: TocHeading[]; │
│ + maxDepth?: number; │
│ +} │
│ + │
│ +const MIN_DOTS = 3; │
│ + │
│ +export function layoutToc(headings: TocHead │
│ ing[], width: number, maxDepth = 3): TocRow[ │
│ ] { │
│ + const hs = headings.filter((h) => h.depth │
│ <= maxDepth); │
│ + if (!hs.length) return []; │
│ + │
│ + const min = Math.min(...hs.map((h) => h.d │
│ epth)); │
│ + const counters: number[] = []; │
│ + │
│ + return hs.map((h) => { │
│ + const level = h.depth - min; │
│ + counters.splice(level + 1); │
│ + counters[level] = (counters[level] ?? 0 │
│ ) + 1; │
│ + const marker = `?${counters.slice(0, le │
│ vel + 1).map((c) => c ?? 1).join(".")}`; │
│ + │
│ + const indent = " ".repeat(level); │
│ + // Astro extracts heading text after re │
│ hype-ascii has appended the │
│ + // h1 head-rule, so box-drawing runs mu │
│ st be stripped back out. │
│ + let title = h.text.replace(/[─-▟]+/gu, │
│ "").replace(/\s+/g, " ").trim().toUpperCase( │
│ ); │
│ + const maxTitle = width - len(indent) - │
│ len(marker) - 2 - MIN_DOTS; │
│ + if (len(title) > maxTitle) title = [... │
│ title].slice(0, maxTitle - 1).join("") + ".. │
│ ."; │
│ + │
│ + const leader = ".".repeat(width - len(i │
│ ndent) - len(title) - len(marker) - 2); │
│ + checkGrid(`${indent}${title} ${leader} │
│ ${marker}`, width, "toc row"); │
│ + return { indent, title, leader, marker, │
│ slug: h.slug }; │
│ + }); │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Timeline/Timeline.astro ─────────────────────┐
│ diff --git a/ooknet-design/src/components/Timeline/Timeline.astro b/ooknet-design/ │
│ src/components/Timeline/Timeline.astro │
│ new file mode 100644 │
│ index 0000000..dfdc6aa │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Timeline/Timeline.astro │
│ @@ -0,0 +1,22 @@ │
│ +--- │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import { FRAME_W, MOBILE_FRAME_W } from "../../lib/config"; │
│ +import { layoutTimeline, type Props, type TimelineRow } from "./Timeline"; │
│ +import "./Timeline.scss"; │
│ + │
│ +const { entries } = Astro.props as Props; │
│ + │
│ +const variants: [string, TimelineRow[]][] = [ │
│ + ["frame-wide", layoutTimeline(entries, FRAME_W)], │
│ + ["frame-narrow", layoutTimeline(entries, MOBILE_FRAME_W)], │
│ +]; │
│ +--- │
│ +{variants.map(([variant, rows]) => │
│ + rows.map((r) => ( │
│ + <Pre class={`${variant} timeline-row`}>{r.prefix}{ │
│ + r.href ? <a class="timeline-link" href={r.href}>{r.text}</a> │
│ + : r.soft ? <span class="timeline-soft">{r.text}</span> │
│ + : r.text │
│ + }</Pre> │
│ + )) │
│ +)} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nents/Timeline/Timeline.astro ───┐
│ diff --git a/ooknet-design/src/components/Ti │
│ meline/Timeline.astro b/ooknet-design/src/co │
│ mponents/Timeline/Timeline.astro │
│ new file mode 100644 │
│ index 0000000..dfdc6aa │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Timeline/ │
│ Timeline.astro │
│ @@ -0,0 +1,22 @@ │
│ +--- │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import { FRAME_W, MOBILE_FRAME_W } from ".. │
│ /../lib/config"; │
│ +import { layoutTimeline, type Props, type T │
│ imelineRow } from "./Timeline"; │
│ +import "./Timeline.scss"; │
│ + │
│ +const { entries } = Astro.props as Props; │
│ + │
│ +const variants: [string, TimelineRow[]][] = │
│ [ │
│ + ["frame-wide", layoutTimeline(entries, FR │
│ AME_W)], │
│ + ["frame-narrow", layoutTimeline(entries, │
│ MOBILE_FRAME_W)], │
│ +]; │
│ +--- │
│ +{variants.map(([variant, rows]) => │
│ + rows.map((r) => ( │
│ + <Pre class={`${variant} timeline-row`}> │
│ {r.prefix}{ │
│ + r.href ? <a class="timeline-link" hre │
│ f={r.href}>{r.text}</a> │
│ + : r.soft ? <span class="timeline-soft │
│ ">{r.text}</span> │
│ + : r.text │
│ + }</Pre> │
│ + )) │
│ +)} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Timeline/Timeline.scss ──────────────────────┐
│ diff --git a/ooknet-design/src/components/Timeline/Timeline.scss b/ooknet-design/s │
│ rc/components/Timeline/Timeline.scss │
│ new file mode 100644 │
│ index 0000000..9b50ff8 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Timeline/Timeline.scss │
│ @@ -0,0 +1,18 @@ │
│ +.timeline-link { │
│ + color: var(--ink); │
│ + text-decoration: none; │
│ + │
│ + &:hover { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ +} │
│ + │
│ +.timeline-soft { │
│ + color: var(--ink-soft); │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...onents/Timeline/Timeline.scss ───┐
│ diff --git a/ooknet-design/src/components/Ti │
│ meline/Timeline.scss b/ooknet-design/src/com │
│ ponents/Timeline/Timeline.scss │
│ new file mode 100644 │
│ index 0000000..9b50ff8 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Timeline/ │
│ Timeline.scss │
│ @@ -0,0 +1,18 @@ │
│ +.timeline-link { │
│ + color: var(--ink); │
│ + text-decoration: none; │
│ + │
│ + &:hover { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ +} │
│ + │
│ +.timeline-soft { │
│ + color: var(--ink-soft); │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Timeline/Timeline.test.ts ───────────────────┐
│ diff --git a/ooknet-design/src/components/Timeline/Timeline.test.ts b/ooknet-desig │
│ n/src/components/Timeline/Timeline.test.ts │
│ new file mode 100644 │
│ index 0000000..03f6c38 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Timeline/Timeline.test.ts │
│ @@ -0,0 +1,37 @@ │
│ +import { describe, expect, it } from "vitest"; │
│ +import { layoutTimeline } from "./Timeline"; │
│ +import { G, len } from "../../lib/ascii"; │
│ + │
│ +const E = (date: string, title: string, desc?: string) => ({ date, title, desc }) │
│ ; │
│ + │
│ +describe("layoutTimeline", () => { │
│ + it("opens the spine with ┬ and continues with ┼", () => { │
│ + const rows = layoutTimeline([E("2026-01-01", "A"), E("2026-01-02", "B"), E("2 │
│ 026-01-03", "C")], 48); │
│ + const junctions = rows.filter((r) => !r.soft).map((r) => [...r.prefix][12]); │
│ + expect(junctions).toEqual([G.TJ, G.X, G.X]); │
│ + }); │
│ + │
│ + it("renders a lone entry without a spine", () => { │
│ + const rows = layoutTimeline([E("2026-01-01", "ONLY")], 48); │
│ + expect(rows).toHaveLength(1); │
│ + expect(rows[0].prefix).toContain(`${G.H}${G.H}${G.H}`); │
│ + }); │
│ + │
│ + it("aligns the rail under the junction", () => { │
│ + const rows = layoutTimeline([E("2026-01-01", "A", "some detail"), E("2026-01- │
│ 02", "B")], 48); │
│ + const jCol = [...rows[0].prefix].indexOf(G.TJ); │
│ + expect([...rows[1].prefix].indexOf(G.V)).toBe(jCol); │
│ + }); │
│ + │
│ + it("wraps descriptions inside the frame", () => { │
│ + const rows = layoutTimeline([E("2026-01-01", "A", "word ".repeat(30).trim()), │
│ E("2026-01-02", "B")], 48); │
│ + for (const r of rows) expect(len(r.prefix + r.text)).toBeLessThanOrEqual(48); │
│ + expect(rows.filter((r) => r.soft && r.text).length).toBeGreaterThan(1); │
│ + }); │
│ + │
│ + it("truncates overlong titles with an ellipsis", () => { │
│ + const rows = layoutTimeline([E("2026-01-01", "x".repeat(80)), E("2026-01-02", │
│ "B")], 48); │
│ + expect(rows[0].text.endsWith("...")).toBe(true); │
│ + expect(len(rows[0].prefix + rows[0].text)).toBeLessThanOrEqual(48); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nts/Timeline/Timeline.test.ts ───┐
│ diff --git a/ooknet-design/src/components/Ti │
│ meline/Timeline.test.ts b/ooknet-design/src/ │
│ components/Timeline/Timeline.test.ts │
│ new file mode 100644 │
│ index 0000000..03f6c38 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Timeline/ │
│ Timeline.test.ts │
│ @@ -0,0 +1,37 @@ │
│ +import { describe, expect, it } from "vites │
│ t"; │
│ +import { layoutTimeline } from "./Timeline" │
│ ; │
│ +import { G, len } from "../../lib/ascii"; │
│ + │
│ +const E = (date: string, title: string, des │
│ c?: string) => ({ date, title, desc }); │
│ + │
│ +describe("layoutTimeline", () => { │
│ + it("opens the spine with ┬ and continues │
│ with ┼", () => { │
│ + const rows = layoutTimeline([E("2026-01 │
│ -01", "A"), E("2026-01-02", "B"), E("2026-01 │
│ -03", "C")], 48); │
│ + const junctions = rows.filter((r) => !r │
│ .soft).map((r) => [...r.prefix][12]); │
│ + expect(junctions).toEqual([G.TJ, G.X, G │
│ .X]); │
│ + }); │
│ + │
│ + it("renders a lone entry without a spine" │
│ , () => { │
│ + const rows = layoutTimeline([E("2026-01 │
│ -01", "ONLY")], 48); │
│ + expect(rows).toHaveLength(1); │
│ + expect(rows[0].prefix).toContain(`${G.H │
│ }${G.H}${G.H}`); │
│ + }); │
│ + │
│ + it("aligns the rail under the junction", │
│ () => { │
│ + const rows = layoutTimeline([E("2026-01 │
│ -01", "A", "some detail"), E("2026-01-02", " │
│ B")], 48); │
│ + const jCol = [...rows[0].prefix].indexO │
│ f(G.TJ); │
│ + expect([...rows[1].prefix].indexOf(G.V) │
│ ).toBe(jCol); │
│ + }); │
│ + │
│ + it("wraps descriptions inside the frame", │
│ () => { │
│ + const rows = layoutTimeline([E("2026-01 │
│ -01", "A", "word ".repeat(30).trim()), E("20 │
│ 26-01-02", "B")], 48); │
│ + for (const r of rows) expect(len(r.pref │
│ ix + r.text)).toBeLessThanOrEqual(48); │
│ + expect(rows.filter((r) => r.soft && r.t │
│ ext).length).toBeGreaterThan(1); │
│ + }); │
│ + │
│ + it("truncates overlong titles with an ell │
│ ipsis", () => { │
│ + const rows = layoutTimeline([E("2026-01 │
│ -01", "x".repeat(80)), E("2026-01-02", "B")] │
│ , 48); │
│ + expect(rows[0].text.endsWith("...")).to │
│ Be(true); │
│ + expect(len(rows[0].prefix + rows[0].tex │
│ t)).toBeLessThanOrEqual(48); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Timeline/Timeline.ts ────────────────────────┐
│ diff --git a/ooknet-design/src/components/Timeline/Timeline.ts b/ooknet-design/src │
│ /components/Timeline/Timeline.ts │
│ new file mode 100644 │
│ index 0000000..e1ef506 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Timeline/Timeline.ts │
│ @@ -0,0 +1,53 @@ │
│ +// Vertical timeline: date column, spine junction, entry title, wrapped │
│ +// description beside the rail. Returns rows so the component can link │
│ +// titles. │
│ +import { G, checkGrid, len, pad, wrap } from "../../lib/ascii"; │
│ + │
│ +export interface TimelineEntry { │
│ + date: string; │
│ + title: string; │
│ + desc?: string; │
│ + href?: string; │
│ +} │
│ + │
│ +export interface TimelineRow { │
│ + prefix: string; │
│ + text: string; │
│ + href?: string; │
│ + /** Description / spacer rows render in the soft ink. */ │
│ + soft?: boolean; │
│ +} │
│ + │
│ +export interface Props { │
│ + entries: TimelineEntry[]; │
│ +} │
│ + │
│ +export function layoutTimeline(entries: TimelineEntry[], width: number): Timeline │
│ Row[] { │
│ + if (!entries.length) return []; │
│ + │
│ + const dateW = Math.max(...entries.map((e) => len(e.date))); │
│ + const textW = width - dateW - 5; │
│ + const rail = `${" ".repeat(dateW + 2)}${G.V} `; │
│ + const out: TimelineRow[] = []; │
│ + │
│ + entries.forEach((e, i) => { │
│ + // ┬ opens the spine, ┼ continues it; a lone entry needs no spine. │
│ + const j = entries.length === 1 ? G.H : i === 0 ? G.TJ : G.X; │
│ + const title = len(e.title) > textW ? [...e.title].slice(0, textW - 1).join("" │
│ ) + "..." : e.title; │
│ + const prefix = `${pad(e.date, dateW)} ${G.H}${j}${G.H} `; │
│ + checkGrid(prefix + title, width, "timeline entry"); │
│ + out.push({ prefix, text: title, href: e.href }); │
│ + │
│ + if (e.desc) { │
│ + for (const ln of wrap(e.desc, textW).split("\n")) { │
│ + checkGrid(rail + ln, width, "timeline desc"); │
│ + out.push({ prefix: rail, text: ln, soft: true }); │
│ + } │
│ + } │
│ + if (i < entries.length - 1) { │
│ + out.push({ prefix: `${" ".repeat(dateW + 2)}${G.V}`, text: "", soft: true } │
│ ); │
│ + } │
│ + }); │
│ + │
│ + return out; │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...mponents/Timeline/Timeline.ts ───┐
│ diff --git a/ooknet-design/src/components/Ti │
│ meline/Timeline.ts b/ooknet-design/src/compo │
│ nents/Timeline/Timeline.ts │
│ new file mode 100644 │
│ index 0000000..e1ef506 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Timeline/ │
│ Timeline.ts │
│ @@ -0,0 +1,53 @@ │
│ +// Vertical timeline: date column, spine ju │
│ nction, entry title, wrapped │
│ +// description beside the rail. Returns row │
│ s so the component can link │
│ +// titles. │
│ +import { G, checkGrid, len, pad, wrap } fro │
│ m "../../lib/ascii"; │
│ + │
│ +export interface TimelineEntry { │
│ + date: string; │
│ + title: string; │
│ + desc?: string; │
│ + href?: string; │
│ +} │
│ + │
│ +export interface TimelineRow { │
│ + prefix: string; │
│ + text: string; │
│ + href?: string; │
│ + /** Description / spacer rows render in t │
│ he soft ink. */ │
│ + soft?: boolean; │
│ +} │
│ + │
│ +export interface Props { │
│ + entries: TimelineEntry[]; │
│ +} │
│ + │
│ +export function layoutTimeline(entries: Tim │
│ elineEntry[], width: number): TimelineRow[] │
│ { │
│ + if (!entries.length) return []; │
│ + │
│ + const dateW = Math.max(...entries.map((e) │
│ => len(e.date))); │
│ + const textW = width - dateW - 5; │
│ + const rail = `${" ".repeat(dateW + 2)}${G │
│ .V} `; │
│ + const out: TimelineRow[] = []; │
│ + │
│ + entries.forEach((e, i) => { │
│ + // ┬ opens the spine, ┼ continues it; a │
│ lone entry needs no spine. │
│ + const j = entries.length === 1 ? G.H : │
│ i === 0 ? G.TJ : G.X; │
│ + const title = len(e.title) > textW ? [. │
│ ..e.title].slice(0, textW - 1).join("") + ". │
│ .." : e.title; │
│ + const prefix = `${pad(e.date, dateW)} $ │
│ {G.H}${j}${G.H} `; │
│ + checkGrid(prefix + title, width, "timel │
│ ine entry"); │
│ + out.push({ prefix, text: title, href: e │
│ .href }); │
│ + │
│ + if (e.desc) { │
│ + for (const ln of wrap(e.desc, textW). │
│ split("\n")) { │
│ + checkGrid(rail + ln, width, "timeli │
│ ne desc"); │
│ + out.push({ prefix: rail, text: ln, │
│ soft: true }); │
│ + } │
│ + } │
│ + if (i < entries.length - 1) { │
│ + out.push({ prefix: `${" ".repeat(date │
│ W + 2)}${G.V}`, text: "", soft: true }); │
│ + } │
│ + }); │
│ + │
│ + return out; │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Toast/Toast.astro ───────────────────────────┐
│ diff --git a/ooknet-design/src/components/Toast/Toast.astro b/ooknet-design/src/co │
│ mponents/Toast/Toast.astro │
│ new file mode 100644 │
│ index 0000000..09a32e1 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Toast/Toast.astro │
│ @@ -0,0 +1,41 @@ │
│ +--- │
│ +// Fixed-corner toast region. Fire with window.ooknetToast(message, │
│ +// variant) or a "ooknet:toast" CustomEvent; frames are built │
│ +// client-side by the same tested builder. │
│ +import "./Toast.scss"; │
│ +--- │
│ +<div class="toast-region" aria-live="polite"></div> │
│ +<script> │
│ + import { buildToast } from "./Toast"; │
│ + import type { StatusVariant } from "../Status/Status"; │
│ + │
│ + const region = document.querySelector(".toast-region")!; │
│ + │
│ + function toast(message: string, variant: StatusVariant = "ok") { │
│ + const el = document.createElement("div"); │
│ + el.className = "toast"; │
│ + │
│ + const pre = document.createElement("pre"); │
│ + pre.className = "ascii toast-frame"; │
│ + pre.setAttribute("aria-hidden", "true"); │
│ + pre.textContent = buildToast(message, variant); │
│ + │
│ + const sr = document.createElement("span"); │
│ + sr.className = "sr-only"; │
│ + sr.textContent = message; │
│ + │
│ + el.append(pre, sr); │
│ + region.append(el); │
│ + requestAnimationFrame(() => el.classList.add("toast-in")); │
│ + setTimeout(() => { │
│ + el.classList.remove("toast-in"); │
│ + setTimeout(() => el.remove(), 250); │
│ + }, 2600); │
│ + } │
│ + │
│ + window.ooknetToast = toast; │
│ + document.addEventListener("ooknet:toast", (e) => { │
│ + const d = (e as CustomEvent).detail ?? {}; │
│ + if (d.message) toast(String(d.message), d.variant); │
│ + }); │
│ +</script> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../components/Toast/Toast.astro ───┐
│ diff --git a/ooknet-design/src/components/To │
│ ast/Toast.astro b/ooknet-design/src/componen │
│ ts/Toast/Toast.astro │
│ new file mode 100644 │
│ index 0000000..09a32e1 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Toast/Toa │
│ st.astro │
│ @@ -0,0 +1,41 @@ │
│ +--- │
│ +// Fixed-corner toast region. Fire with win │
│ dow.ooknetToast(message, │
│ +// variant) or a "ooknet:toast" CustomEvent │
│ ; frames are built │
│ +// client-side by the same tested builder. │
│ +import "./Toast.scss"; │
│ +--- │
│ +<div class="toast-region" aria-live="polite │
│ "></div> │
│ +<script> │
│ + import { buildToast } from "./Toast"; │
│ + import type { StatusVariant } from "../St │
│ atus/Status"; │
│ + │
│ + const region = document.querySelector(".t │
│ oast-region")!; │
│ + │
│ + function toast(message: string, variant: │
│ StatusVariant = "ok") { │
│ + const el = document.createElement("div" │
│ ); │
│ + el.className = "toast"; │
│ + │
│ + const pre = document.createElement("pre │
│ "); │
│ + pre.className = "ascii toast-frame"; │
│ + pre.setAttribute("aria-hidden", "true") │
│ ; │
│ + pre.textContent = buildToast(message, v │
│ ariant); │
│ + │
│ + const sr = document.createElement("span │
│ "); │
│ + sr.className = "sr-only"; │
│ + sr.textContent = message; │
│ + │
│ + el.append(pre, sr); │
│ + region.append(el); │
│ + requestAnimationFrame(() => el.classLis │
│ t.add("toast-in")); │
│ + setTimeout(() => { │
│ + el.classList.remove("toast-in"); │
│ + setTimeout(() => el.remove(), 250); │
│ + }, 2600); │
│ + } │
│ + │
│ + window.ooknetToast = toast; │
│ + document.addEventListener("ooknet:toast", │
│ (e) => { │
│ + const d = (e as CustomEvent).detail ?? │
│ {}; │
│ + if (d.message) toast(String(d.message), │
│ d.variant); │
│ + }); │
│ +</script> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Toast/Toast.scss ────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Toast/Toast.scss b/ooknet-design/src/com │
│ ponents/Toast/Toast.scss │
│ new file mode 100644 │
│ index 0000000..3a203ae │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Toast/Toast.scss │
│ @@ -0,0 +1,28 @@ │
│ +.toast-region { │
│ + position: fixed; │
│ + right: 1.25em; │
│ + bottom: 1.25em; │
│ + display: flex; │
│ + flex-direction: column; │
│ + align-items: flex-end; │
│ + gap: 0.5em; │
│ + z-index: 10; │
│ + pointer-events: none; │
│ +} │
│ + │
│ +.toast { │
│ + opacity: 0; │
│ + transform: translateY(4px); │
│ + transition: opacity 0.2s ease, transform 0.2s ease; │
│ + │
│ + &.toast-in { │
│ + opacity: 1; │
│ + transform: none; │
│ + } │
│ + │
│ + pre.ascii.toast-frame { │
│ + width: auto; │
│ + background: var(--paper); │
│ + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08), 0 8px 24px -12px rgba(0, 0, 0, 0.3 │
│ ); │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/components/Toast/Toast.scss ───┐
│ diff --git a/ooknet-design/src/components/To │
│ ast/Toast.scss b/ooknet-design/src/component │
│ s/Toast/Toast.scss │
│ new file mode 100644 │
│ index 0000000..3a203ae │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Toast/Toa │
│ st.scss │
│ @@ -0,0 +1,28 @@ │
│ +.toast-region { │
│ + position: fixed; │
│ + right: 1.25em; │
│ + bottom: 1.25em; │
│ + display: flex; │
│ + flex-direction: column; │
│ + align-items: flex-end; │
│ + gap: 0.5em; │
│ + z-index: 10; │
│ + pointer-events: none; │
│ +} │
│ + │
│ +.toast { │
│ + opacity: 0; │
│ + transform: translateY(4px); │
│ + transition: opacity 0.2s ease, transform │
│ 0.2s ease; │
│ + │
│ + &.toast-in { │
│ + opacity: 1; │
│ + transform: none; │
│ + } │
│ + │
│ + pre.ascii.toast-frame { │
│ + width: auto; │
│ + background: var(--paper); │
│ + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.0 │
│ 8), 0 8px 24px -12px rgba(0, 0, 0, 0.3); │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Toast/Toast.test.ts ─────────────────────────┐
│ diff --git a/ooknet-design/src/components/Toast/Toast.test.ts b/ooknet-design/src/ │
│ components/Toast/Toast.test.ts │
│ new file mode 100644 │
│ index 0000000..7492d9d │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Toast/Toast.test.ts │
│ @@ -0,0 +1,22 @@ │
│ +import { describe, expect, it } from "vitest"; │
│ +import { buildToast, TOAST_W } from "./Toast"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +describe("buildToast", () => { │
│ + it("renders every line at the fixed toast width", () => { │
│ + for (const line of buildToast("transmission queued").split("\n")) { │
│ + expect(len(line)).toBe(TOAST_W); │
│ + } │
│ + }); │
│ + │
│ + it("prefixes the status tag and uppercases the message", () => { │
│ + expect(buildToast("saved", "ok")).toContain("[ OK ] SAVED"); │
│ + expect(buildToast("lost", "error")).toContain("[ !! ] LOST"); │
│ + }); │
│ + │
│ + it("aligns wrapped continuation lines under the message", () => { │
│ + const lines = buildToast("a message long enough to wrap onto more lines").spl │
│ it("\n"); │
│ + expect(lines.length).toBeGreaterThan(3); │
│ + expect(lines[2]).toMatch(/^│ \S/); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...omponents/Toast/Toast.test.ts ───┐
│ diff --git a/ooknet-design/src/components/To │
│ ast/Toast.test.ts b/ooknet-design/src/compon │
│ ents/Toast/Toast.test.ts │
│ new file mode 100644 │
│ index 0000000..7492d9d │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Toast/Toa │
│ st.test.ts │
│ @@ -0,0 +1,22 @@ │
│ +import { describe, expect, it } from "vites │
│ t"; │
│ +import { buildToast, TOAST_W } from "./Toas │
│ t"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +describe("buildToast", () => { │
│ + it("renders every line at the fixed toast │
│ width", () => { │
│ + for (const line of buildToast("transmis │
│ sion queued").split("\n")) { │
│ + expect(len(line)).toBe(TOAST_W); │
│ + } │
│ + }); │
│ + │
│ + it("prefixes the status tag and uppercase │
│ s the message", () => { │
│ + expect(buildToast("saved", "ok")).toCon │
│ tain("[ OK ] SAVED"); │
│ + expect(buildToast("lost", "error")).toC │
│ ontain("[ !! ] LOST"); │
│ + }); │
│ + │
│ + it("aligns wrapped continuation lines und │
│ er the message", () => { │
│ + const lines = buildToast("a message lon │
│ g enough to wrap onto more lines").split("\n │
│ "); │
│ + expect(lines.length).toBeGreaterThan(3) │
│ ; │
│ + expect(lines[2]).toMatch(/^│ \S/ │
│ ); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Toast/Toast.ts ──────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Toast/Toast.ts b/ooknet-design/src/compo │
│ nents/Toast/Toast.ts │
│ new file mode 100644 │
│ index 0000000..5f5f9d7 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Toast/Toast.ts │
│ @@ -0,0 +1,22 @@ │
│ +// Toast frame builder. Runs in the browser (bundled into the Toast │
│ +// component script), so it stays pure and small. │
│ +import { box, len, wrap } from "../../lib/ascii"; │
│ +import { STATUS_TAG, type StatusVariant } from "../Status/Status"; │
│ + │
│ +export const TOAST_W = 36; │
│ + │
│ +declare global { │
│ + interface Window { │
│ + ooknetToast?: (message: string, variant?: StatusVariant) => void; │
│ + } │
│ +} │
│ + │
│ +export function buildToast(message: string, variant: StatusVariant = "ok"): strin │
│ g { │
│ + const tag = STATUS_TAG[variant]; │
│ + const msg = message.replace(/\s+/g, " ").trim().toUpperCase(); │
│ + const bodyW = TOAST_W - 4 - len(tag) - 1; │
│ + const lines = wrap(msg, bodyW) │
│ + .split("\n") │
│ + .map((l, i) => (i === 0 ? ` ${tag} ${l}` : ` ${" ".repeat(len(tag))} ${l}`)); │
│ + return box({ width: TOAST_W, lines, align: "end" }); │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...src/components/Toast/Toast.ts ───┐
│ diff --git a/ooknet-design/src/components/To │
│ ast/Toast.ts b/ooknet-design/src/components/ │
│ Toast/Toast.ts │
│ new file mode 100644 │
│ index 0000000..5f5f9d7 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Toast/Toa │
│ st.ts │
│ @@ -0,0 +1,22 @@ │
│ +// Toast frame builder. Runs in the browser │
│ (bundled into the Toast │
│ +// component script), so it stays pure and │
│ small. │
│ +import { box, len, wrap } from "../../lib/a │
│ scii"; │
│ +import { STATUS_TAG, type StatusVariant } f │
│ rom "../Status/Status"; │
│ + │
│ +export const TOAST_W = 36; │
│ + │
│ +declare global { │
│ + interface Window { │
│ + ooknetToast?: (message: string, variant │
│ ?: StatusVariant) => void; │
│ + } │
│ +} │
│ + │
│ +export function buildToast(message: string, │
│ variant: StatusVariant = "ok"): string { │
│ + const tag = STATUS_TAG[variant]; │
│ + const msg = message.replace(/\s+/g, " "). │
│ trim().toUpperCase(); │
│ + const bodyW = TOAST_W - 4 - len(tag) - 1; │
│ + const lines = wrap(msg, bodyW) │
│ + .split("\n") │
│ + .map((l, i) => (i === 0 ? ` ${tag} ${l} │
│ ` : ` ${" ".repeat(len(tag))} ${l}`)); │
│ + return box({ width: TOAST_W, lines, align │
│ : "end" }); │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/content/notes/n0-style-sheet.md ────────────────────────┐
│ diff --git a/ooknet-design/src/content/notes/n0-style-sheet.md b/ooknet-design/src │
│ /content/notes/n0-style-sheet.md │
│ index 1264e20..f2064fb 100644 │
│ --- a/ooknet-design/src/content/notes/n0-style-sheet.md │
│ +++ b/ooknet-design/src/content/notes/n0-style-sheet.md │
│ @@ -106,6 +106,18 @@ export function pad(s: unknown, n: number): string { │
│ } │
│ ``` │
│ │
│ +A `diff` fence renders additions bold and deletions soft - the │
│ +palette stays monochrome: │
│ + │
│ +```diff │
│ +@@ -1,3 +1,4 @@ │
│ + export function pad(s: unknown, n: number): string { │
│ +- return String(s).padEnd(n); │
│ ++ const str = String(s ?? ""); │
│ ++ return str + " ".repeat(Math.max(0, n - len(str))); │
│ + } │
│ +``` │
│ + │
│ # Lists │
│ │
│ - frames are strings, components are chrome │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ntent/notes/n0-style-sheet.md ───┐
│ diff --git a/ooknet-design/src/content/notes │
│ /n0-style-sheet.md b/ooknet-design/src/conte │
│ nt/notes/n0-style-sheet.md │
│ index 1264e20..f2064fb 100644 │
│ --- a/ooknet-design/src/content/notes/n0-sty │
│ le-sheet.md │
│ +++ b/ooknet-design/src/content/notes/n0-sty │
│ le-sheet.md │
│ @@ -106,6 +106,18 @@ export function pad(s: │
│ unknown, n: number): string { │
│ } │
│ ``` │
│ │
│ +A `diff` fence renders additions bold and d │
│ eletions soft - the │
│ +palette stays monochrome: │
│ + │
│ +```diff │
│ +@@ -1,3 +1,4 @@ │
│ + export function pad(s: unknown, n: number) │
│ : string { │
│ +- return String(s).padEnd(n); │
│ ++ const str = String(s ?? ""); │
│ ++ return str + " ".repeat(Math.max(0, n - │
│ len(str))); │
│ + } │
│ +``` │
│ + │
│ # Lists │
│ │
│ - frames are strings, components are chrome │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/layouts/ArticleLayout/ArticleLayout.astro ──────────────┐
│ diff --git a/ooknet-design/src/layouts/ArticleLayout/ArticleLayout.astro b/ooknet- │
│ design/src/layouts/ArticleLayout/ArticleLayout.astro │
│ index a363a6e..786ce49 100644 │
│ --- a/ooknet-design/src/layouts/ArticleLayout/ArticleLayout.astro │
│ +++ b/ooknet-design/src/layouts/ArticleLayout/ArticleLayout.astro │
│ @@ -8,6 +8,7 @@ import "@fontsource/cascadia-mono/400.css"; │
│ import "@fontsource/cascadia-mono/700.css"; │
│ import "../../styles/global.scss"; │
│ import "./ArticleLayout.scss"; │
│ +import Toast from "../../components/Toast/Toast.astro"; │
│ const { title = "OOKNET KB" } = Astro.props; │
│ --- │
│ <!doctype html> │
│ @@ -31,6 +32,7 @@ const { title = "OOKNET KB" } = Astro.props; │
│ <div class="page"> │
│ <slot /> │
│ </div> │
│ + <Toast /> │
│ <script is:inline> │
│ document.addEventListener("click", async (e) => { │
│ const copyBtn = e.target.closest(".code-copy"); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...cleLayout/ArticleLayout.astro ───┐
│ diff --git a/ooknet-design/src/layouts/Artic │
│ leLayout/ArticleLayout.astro b/ooknet-design │
│ /src/layouts/ArticleLayout/ArticleLayout.ast │
│ ro │
│ index a363a6e..786ce49 100644 │
│ --- a/ooknet-design/src/layouts/ArticleLayou │
│ t/ArticleLayout.astro │
│ +++ b/ooknet-design/src/layouts/ArticleLayou │
│ t/ArticleLayout.astro │
│ @@ -8,6 +8,7 @@ import "@fontsource/cascadia │
│ -mono/400.css"; │
│ import "@fontsource/cascadia-mono/700.css"; │
│ import "../../styles/global.scss"; │
│ import "./ArticleLayout.scss"; │
│ +import Toast from "../../components/Toast/T │
│ oast.astro"; │
│ const { title = "OOKNET KB" } = Astro.props │
│ ; │
│ --- │
│ <!doctype html> │
│ @@ -31,6 +32,7 @@ const { title = "OOKNET KB │
│ " } = Astro.props; │
│ <div class="page"> │
│ <slot /> │
│ </div> │
│ + <Toast /> │
│ <script is:inline> │
│ document.addEventListener("click", as │
│ ync (e) => { │
│ const copyBtn = e.target.closest(". │
│ code-copy"); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/lib/rehype-ascii.test.ts ───────────────────────────────┐
│ diff --git a/ooknet-design/src/lib/rehype-ascii.test.ts b/ooknet-design/src/lib/re │
│ hype-ascii.test.ts │
│ new file mode 100644 │
│ index 0000000..7179e33 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/lib/rehype-ascii.test.ts │
│ @@ -0,0 +1,22 @@ │
│ +import { describe, expect, it } from "vitest"; │
│ +import { diffLineClass } from "./rehype-ascii"; │
│ + │
│ +describe("diffLineClass", () => { │
│ + it("classifies additions and deletions", () => { │
│ + expect(diffLineClass("+ added line")).toBe("diff-add"); │
│ + expect(diffLineClass("- removed line")).toBe("diff-del"); │
│ + }); │
│ + │
│ + it("classifies hunk headers and file metadata", () => { │
│ + expect(diffLineClass("@@ -1,4 +1,4 @@")).toBe("diff-hunk"); │
│ + expect(diffLineClass("--- a/src/lib/ascii.ts")).toBe("diff-meta"); │
│ + expect(diffLineClass("+++ b/src/lib/ascii.ts")).toBe("diff-meta"); │
│ + expect(diffLineClass("diff --git a/x b/x")).toBe("diff-meta"); │
│ + expect(diffLineClass("index 3f2d1a..9c8b7e 100644")).toBe("diff-meta"); │
│ + }); │
│ + │
│ + it("leaves context lines unclassified", () => { │
│ + expect(diffLineClass(" const str = String(s);")).toBeNull(); │
│ + expect(diffLineClass("")).toBeNull(); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../src/lib/rehype-ascii.test.ts ───┐
│ diff --git a/ooknet-design/src/lib/rehype-as │
│ cii.test.ts b/ooknet-design/src/lib/rehype-a │
│ scii.test.ts │
│ new file mode 100644 │
│ index 0000000..7179e33 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/lib/rehype-ascii.tes │
│ t.ts │
│ @@ -0,0 +1,22 @@ │
│ +import { describe, expect, it } from "vites │
│ t"; │
│ +import { diffLineClass } from "./rehype-asc │
│ ii"; │
│ + │
│ +describe("diffLineClass", () => { │
│ + it("classifies additions and deletions", │
│ () => { │
│ + expect(diffLineClass("+ added line")). │
│ toBe("diff-add"); │
│ + expect(diffLineClass("- removed line") │
│ ).toBe("diff-del"); │
│ + }); │
│ + │
│ + it("classifies hunk headers and file meta │
│ data", () => { │
│ + expect(diffLineClass("@@ -1,4 +1,4 @@") │
│ ).toBe("diff-hunk"); │
│ + expect(diffLineClass("--- a/src/lib/asc │
│ ii.ts")).toBe("diff-meta"); │
│ + expect(diffLineClass("+++ b/src/lib/asc │
│ ii.ts")).toBe("diff-meta"); │
│ + expect(diffLineClass("diff --git a/x b/ │
│ x")).toBe("diff-meta"); │
│ + expect(diffLineClass("index 3f2d1a..9c8 │
│ b7e 100644")).toBe("diff-meta"); │
│ + }); │
│ + │
│ + it("leaves context lines unclassified", ( │
│ ) => { │
│ + expect(diffLineClass(" const str = Str │
│ ing(s);")).toBeNull(); │
│ + expect(diffLineClass("")).toBeNull(); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/lib/rehype-ascii.ts ────────────────────────────────────┐
│ diff --git a/ooknet-design/src/lib/rehype-ascii.ts b/ooknet-design/src/lib/rehype- │
│ ascii.ts │
│ index 4e8152a..968505e 100644 │
│ --- a/ooknet-design/src/lib/rehype-ascii.ts │
│ +++ b/ooknet-design/src/lib/rehype-ascii.ts │
│ @@ -56,13 +56,24 @@ const copyBtn: Element = { │
│ │
│ // ---------------------------------------------------------------- code │
│ │
│ +/** Line class for ```diff fences; null renders as plain code. */ │
│ +export function diffLineClass(line: string): string | null { │
│ + if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("diff " │
│ ) || line.startsWith("index ")) return "diff-meta"; │
│ + if (line.startsWith("@@")) return "diff-hunk"; │
│ + if (line.startsWith("+")) return "diff-add"; │
│ + if (line.startsWith("-")) return "diff-del"; │
│ + return null; │
│ +} │
│ + │
│ function buildBoxChildren(text: string, lang: string, frameW: number): ElementCon │
│ tent[] { │
│ const inner = frameW - 4; │
│ const lines = text.replace(/\n+$/, "").split("\n").flatMap((line) => { │
│ + // Classified before wrapping so continuation chunks keep the class. │
│ + const cls = lang === "diff" ? diffLineClass(line) : null; │
│ const cps = [...line]; │
│ - if (cps.length <= inner) return [line]; │
│ - const chunks: string[] = []; │
│ - for (let i = 0; i < cps.length; i += inner) chunks.push(cps.slice(i, i + inne │
│ r).join("")); │
│ + if (cps.length <= inner) return [{ text: line, cls }]; │
│ + const chunks: { text: string; cls: string | null }[] = []; │
│ + for (let i = 0; i < cps.length; i += inner) chunks.push({ text: cps.slice(i, │
│ i + inner).join(""), cls }); │
│ return chunks; │
│ }); │
│ │
│ @@ -80,22 +91,31 @@ function buildBoxChildren(text: string, lang: string, frameW: │
│ number): ElementCo │
│ { type: "text", value: "\n" }, │
│ ]; │
│ │
│ - for (const line of lines) { │
│ + for (const { text: line, cls } of lines) { │
│ checkGrid(line, inner, "code block"); │
│ const padLen = Math.max(0, inner - len(line)); │
│ children.push(frame(`${G.V} `)); │
│ - let last = 0; │
│ - for (const m of line.matchAll(FN_RE)) { │
│ - if (m.index! > last) children.push({ type: "text", value: line.slice(last, │
│ m.index) }); │
│ + if (cls) { │
│ children.push({ │
│ type: "element", │
│ - tagName: "strong", │
│ - properties: {}, │
│ - children: [{ type: "text", value: m[0] }], │
│ + tagName: "span", │
│ + properties: { className: [cls] }, │
│ + children: [{ type: "text", value: line }], │
│ }); │
│ - last = m.index! + m[0].length; │
│ + } else { │
│ + let last = 0; │
│ + for (const m of line.matchAll(FN_RE)) { │
│ + if (m.index! > last) children.push({ type: "text", value: line.slice(last │
│ , m.index) }); │
│ + children.push({ │
│ + type: "element", │
│ + tagName: "strong", │
│ + properties: {}, │
│ + children: [{ type: "text", value: m[0] }], │
│ + }); │
│ + last = m.index! + m[0].length; │
│ + } │
│ + if (last < line.length) children.push({ type: "text", value: line.slice(las │
│ t) }); │
│ } │
│ - if (last < line.length) children.push({ type: "text", value: line.slice(last) │
│ }); │
│ children.push(frame(`${" ".repeat(padLen)} ${G.V}`)); │
│ children.push({ type: "text", value: "\n" }); │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...esign/src/lib/rehype-ascii.ts ───┐
│ diff --git a/ooknet-design/src/lib/rehype-as │
│ cii.ts b/ooknet-design/src/lib/rehype-ascii. │
│ ts │
│ index 4e8152a..968505e 100644 │
│ --- a/ooknet-design/src/lib/rehype-ascii.ts │
│ +++ b/ooknet-design/src/lib/rehype-ascii.ts │
│ @@ -56,13 +56,24 @@ const copyBtn: Element = │
│ { │
│ │
│ // ---------------------------------------- │
│ ------------------------ code │
│ │
│ +/** Line class for ```diff fences; null ren │
│ ders as plain code. */ │
│ +export function diffLineClass(line: string) │
│ : string | null { │
│ + if (line.startsWith("+++") || line.starts │
│ With("---") || line.startsWith("diff ") || l │
│ ine.startsWith("index ")) return "diff-meta" │
│ ; │
│ + if (line.startsWith("@@")) return "diff-h │
│ unk"; │
│ + if (line.startsWith("+")) return "diff-ad │
│ d"; │
│ + if (line.startsWith("-")) return "diff-de │
│ l"; │
│ + return null; │
│ +} │
│ + │
│ function buildBoxChildren(text: string, lan │
│ g: string, frameW: number): ElementContent[] │
│ { │
│ const inner = frameW - 4; │
│ const lines = text.replace(/\n+$/, "").sp │
│ lit("\n").flatMap((line) => { │
│ + // Classified before wrapping so contin │
│ uation chunks keep the class. │
│ + const cls = lang === "diff" ? diffLineC │
│ lass(line) : null; │
│ const cps = [...line]; │
│ - if (cps.length <= inner) return [line]; │
│ - const chunks: string[] = []; │
│ - for (let i = 0; i < cps.length; i += in │
│ ner) chunks.push(cps.slice(i, i + inner).joi │
│ n("")); │
│ + if (cps.length <= inner) return [{ text │
│ : line, cls }]; │
│ + const chunks: { text: string; cls: stri │
│ ng | null }[] = []; │
│ + for (let i = 0; i < cps.length; i += in │
│ ner) chunks.push({ text: cps.slice(i, i + in │
│ ner).join(""), cls }); │
│ return chunks; │
│ }); │
│ │
│ @@ -80,22 +91,31 @@ function buildBoxChildre │
│ n(text: string, lang: string, frameW: number │
│ ): ElementCo │
│ { type: "text", value: "\n" }, │
│ ]; │
│ │
│ - for (const line of lines) { │
│ + for (const { text: line, cls } of lines) │
│ { │
│ checkGrid(line, inner, "code block"); │
│ const padLen = Math.max(0, inner - len( │
│ line)); │
│ children.push(frame(`${G.V} `)); │
│ - let last = 0; │
│ - for (const m of line.matchAll(FN_RE)) { │
│ - if (m.index! > last) children.push({ │
│ type: "text", value: line.slice(last, m.inde │
│ x) }); │
│ + if (cls) { │
│ children.push({ │
│ type: "element", │
│ - tagName: "strong", │
│ - properties: {}, │
│ - children: [{ type: "text", value: m │
│ [0] }], │
│ + tagName: "span", │
│ + properties: { className: [cls] }, │
│ + children: [{ type: "text", value: l │
│ ine }], │
│ }); │
│ - last = m.index! + m[0].length; │
│ + } else { │
│ + let last = 0; │
│ + for (const m of line.matchAll(FN_RE)) │
│ { │
│ + if (m.index! > last) children.push( │
│ { type: "text", value: line.slice(last, m.in │
│ dex) }); │
│ + children.push({ │
│ + type: "element", │
│ + tagName: "strong", │
│ + properties: {}, │
│ + children: [{ type: "text", value: │
│ m[0] }], │
│ + }); │
│ + last = m.index! + m[0].length; │
│ + } │
│ + if (last < line.length) children.push │
│ ({ type: "text", value: line.slice(last) }); │
│ } │
│ - if (last < line.length) children.push({ │
│ type: "text", value: line.slice(last) }); │
│ children.push(frame(`${" ".repeat(padLe │
│ n)} ${G.V}`)); │
│ children.push({ type: "text", value: "\ │
│ n" }); │
│ } │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/pages/components.astro ─────────────────────────────────┐
│ diff --git a/ooknet-design/src/pages/components.astro b/ooknet-design/src/pages/co │
│ mponents.astro │
│ index 106ca2c..6f54486 100644 │
│ --- a/ooknet-design/src/pages/components.astro │
│ +++ b/ooknet-design/src/pages/components.astro │
│ @@ -15,6 +15,10 @@ import type { TreeNode } from "../components/Tree/Tree"; │
│ import Progress from "../components/Progress/Progress.astro"; │
│ import Spinner from "../components/Spinner/Spinner.astro"; │
│ import Status from "../components/Status/Status.astro"; │
│ +import TableOfContents from "../components/TableOfContents/TableOfContents.astro" │
│ ; │
│ +import Accordion from "../components/Accordion/Accordion.astro"; │
│ +import Timeline from "../components/Timeline/Timeline.astro"; │
│ +import type { TimelineEntry } from "../components/Timeline/Timeline"; │
│ import { rule, ruleD, row2, pad } from "../lib/ascii"; │
│ │
│ const section = (title: string, right: string) => (w: number) => │
│ @@ -37,6 +41,22 @@ const REPORT_FIELDS: Field[] = [ │
│ { kind: "buttons", buttons: [{ label: "TRANSMIT", variant: "primary" }, { label │
│ : "CLEAR", type: "reset" }] }, │
│ ]; │
│ │
│ +const DEMO_HEADINGS = [ │
│ + { depth: 1, slug: "buttons", text: "Buttons" }, │
│ + { depth: 1, slug: "text-fields", text: "Text fields" }, │
│ + { depth: 1, slug: "toggles", text: "Toggles" }, │
│ + { depth: 2, slug: "checkboxes", text: "Checkboxes" }, │
│ + { depth: 2, slug: "radios", text: "Radios" }, │
│ + { depth: 1, slug: "form-frame", text: "Form frame" }, │
│ +]; │
│ + │
│ +const HISTORY: TimelineEntry[] = [ │
│ + { date: "2026-07-16", title: "N0 STYLE SHEET FILED", desc: "exercises every con │
│ struct the pipeline renders", href: "/notes/n0-style-sheet/" }, │
│ + { date: "2026-04-27", title: "N219 FILED", desc: "using AI to troubleshoot and │
│ resolve issues", href: "/notes/n219-using-ai-to-troubleshoot/" }, │
│ + { date: "2026-04-25", title: "N218 FILED", href: "/notes/collect-data-to-analyz │
│ e-and-troubleshoot-performance-scenarios/" }, │
│ + { date: "2026-04-19", title: "N217 FILED", desc: "entropy bounds in sparse grap │
│ hs", href: "/notes/n217-entropy-bounds/" }, │
│ +]; │
│ + │
│ const FILED_TREE: TreeNode[] = [ │
│ { │
│ label: "/INFO", │
│ @@ -117,5 +137,40 @@ const FILED_TREE: TreeNode[] = [ │
│ <Pre><Spinner />{" SYNCING INDEX"}</Pre> │
│ <Pre>{" "}</Pre> │
│ │
│ + <Frame build={section("TABLE OF CONTENTS", "TableOfContents.astro")} /> │
│ + <TableOfContents headings={DEMO_HEADINGS} /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("ACCORDION", "Accordion.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Accordion label="APPENDIX A - CELL MEASUREMENTS"> │
│ + <p>Cascadia Mono "0" advance at 13px: 7.617px. IBM Plex Mono drifts on box-dr │
│ awing glyphs, which is why it never draws frames.</p> │
│ + </Accordion> │
│ + <Accordion label="APPENDIX B - DROPPED GLYPH SETS"> │
│ + <p>Heavy box-drawing (U+250F etc.) fell back to a wider face and bent every b │
│ order it touched. Dropped from the grid.</p> │
│ + </Accordion> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("TIMELINE", "Timeline.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Timeline entries={HISTORY} /> │
│ + <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> │
│ + <Pre>{" "}</Pre> │
│ + │
│ <Frame build={(w) => rule(w)} /> │
│ </ArticleLayout> │
│ + │
│ +<script> │
│ + import type { StatusVariant } from "../components/Status/Status"; │
│ + │
│ + const wire = (id: string, message: string, variant: StatusVariant) => │
│ + document.querySelector(`#${id} .btn`)?.addEventListener("click", () => window │
│ .ooknetToast?.(message, variant)); │
│ + │
│ + wire("toast-ok", "TRANSMISSION QUEUED", "ok"); │
│ + wire("toast-error", "FREQUENCY OUT OF BAND", "error"); │
│ + wire("toast-pending", "AWAITING PEER CHECK", "pending"); │
│ +</script> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...gn/src/pages/components.astro ───┐
│ diff --git a/ooknet-design/src/pages/compone │
│ nts.astro b/ooknet-design/src/pages/componen │
│ ts.astro │
│ index 106ca2c..6f54486 100644 │
│ --- a/ooknet-design/src/pages/components.ast │
│ ro │
│ +++ b/ooknet-design/src/pages/components.ast │
│ ro │
│ @@ -15,6 +15,10 @@ import type { TreeNode } │
│ from "../components/Tree/Tree"; │
│ import Progress from "../components/Progres │
│ s/Progress.astro"; │
│ import Spinner from "../components/Spinner/ │
│ Spinner.astro"; │
│ import Status from "../components/Status/St │
│ atus.astro"; │
│ +import TableOfContents from "../components/ │
│ TableOfContents/TableOfContents.astro"; │
│ +import Accordion from "../components/Accord │
│ ion/Accordion.astro"; │
│ +import Timeline from "../components/Timelin │
│ e/Timeline.astro"; │
│ +import type { TimelineEntry } from "../comp │
│ onents/Timeline/Timeline"; │
│ import { rule, ruleD, row2, pad } from "../ │
│ lib/ascii"; │
│ │
│ const section = (title: string, right: stri │
│ ng) => (w: number) => │
│ @@ -37,6 +41,22 @@ const REPORT_FIELDS: Fiel │
│ d[] = [ │
│ { kind: "buttons", buttons: [{ label: "TR │
│ ANSMIT", variant: "primary" }, { label: "CLE │
│ AR", type: "reset" }] }, │
│ ]; │
│ │
│ +const DEMO_HEADINGS = [ │
│ + { depth: 1, slug: "buttons", text: "Butto │
│ ns" }, │
│ + { depth: 1, slug: "text-fields", text: "T │
│ ext fields" }, │
│ + { depth: 1, slug: "toggles", text: "Toggl │
│ es" }, │
│ + { depth: 2, slug: "checkboxes", text: "Ch │
│ eckboxes" }, │
│ + { depth: 2, slug: "radios", text: "Radios │
│ " }, │
│ + { depth: 1, slug: "form-frame", text: "Fo │
│ rm frame" }, │
│ +]; │
│ + │
│ +const HISTORY: TimelineEntry[] = [ │
│ + { date: "2026-07-16", title: "N0 STYLE SH │
│ EET FILED", desc: "exercises every construct │
│ the pipeline renders", href: "/notes/n0-sty │
│ le-sheet/" }, │
│ + { date: "2026-04-27", title: "N219 FILED" │
│ , desc: "using AI to troubleshoot and resolv │
│ e issues", href: "/notes/n219-using-ai-to-tr │
│ oubleshoot/" }, │
│ + { date: "2026-04-25", title: "N218 FILED" │
│ , href: "/notes/collect-data-to-analyze-and- │
│ troubleshoot-performance-scenarios/" }, │
│ + { date: "2026-04-19", title: "N217 FILED" │
│ , desc: "entropy bounds in sparse graphs", h │
│ ref: "/notes/n217-entropy-bounds/" }, │
│ +]; │
│ + │
│ const FILED_TREE: TreeNode[] = [ │
│ { │
│ label: "/INFO", │
│ @@ -117,5 +137,40 @@ const FILED_TREE: TreeN │
│ ode[] = [ │
│ <Pre><Spinner />{" SYNCING INDEX"}</Pre> │
│ <Pre>{" "}</Pre> │
│ │
│ + <Frame build={section("TABLE OF CONTENTS" │
│ , "TableOfContents.astro")} /> │
│ + <TableOfContents headings={DEMO_HEADINGS} │
│ /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("ACCORDION", "Accor │
│ dion.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Accordion label="APPENDIX A - CELL MEASU │
│ REMENTS"> │
│ + <p>Cascadia Mono "0" advance at 13px: 7 │
│ .617px. IBM Plex Mono drifts on box-drawing │
│ glyphs, which is why it never draws frames.< │
│ /p> │
│ + </Accordion> │
│ + <Accordion label="APPENDIX B - DROPPED GL │
│ YPH SETS"> │
│ + <p>Heavy box-drawing (U+250F etc.) fell │
│ back to a wider face and bent every border │
│ it touched. Dropped from the grid.</p> │
│ + </Accordion> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("TIMELINE", "Timeli │
│ ne.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Timeline entries={HISTORY} /> │
│ + <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> │
│ + <Pre>{" "}</Pre> │
│ + │
│ <Frame build={(w) => rule(w)} /> │
│ </ArticleLayout> │
│ + │
│ +<script> │
│ + import type { StatusVariant } from "../co │
│ mponents/Status/Status"; │
│ + │
│ + const wire = (id: string, message: string │
│ , variant: StatusVariant) => │
│ + document.querySelector(`#${id} .btn`)?. │
│ addEventListener("click", () => window.ookne │
│ tToast?.(message, variant)); │
│ + │
│ + wire("toast-ok", "TRANSMISSION QUEUED", " │
│ ok"); │
│ + wire("toast-error", "FREQUENCY OUT OF BAN │
│ D", "error"); │
│ + wire("toast-pending", "AWAITING PEER CHEC │
│ K", "pending"); │
│ +</script> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/pages/notes/[slug].astro ───────────────────────────────┐
│ diff --git a/ooknet-design/src/pages/notes/[slug].astro b/ooknet-design/src/pages/ │
│ notes/[slug].astro │
│ index 0530758..f039af8 100644 │
│ --- a/ooknet-design/src/pages/notes/[slug].astro │
│ +++ b/ooknet-design/src/pages/notes/[slug].astro │
│ @@ -5,6 +5,7 @@ import TopHeader from "../../components/TopHeader/TopHeader.astro" │
│ ; │
│ import ArticleHeader from "../../components/ArticleHeader/ArticleHeader.astro"; │
│ import DocumentTitle from "../../components/DocumentTitle/DocumentTitle.astro"; │
│ import ArticleMeta from "../../components/ArticleMeta/ArticleMeta.astro"; │
│ +import TableOfContents from "../../components/TableOfContents/TableOfContents.ast │
│ ro"; │
│ import ArticleSeeAlso from "../../components/ArticleSeeAlso/ArticleSeeAlso.astro" │
│ ; │
│ import ArticleTags from "../../components/ArticleTags/ArticleTags.astro"; │
│ import ArticlePagination from "../../components/ArticlePagination/ArticlePaginati │
│ on.astro"; │
│ @@ -30,13 +31,14 @@ interface Props { │
│ next: PageLink | null; │
│ } │
│ const { note, prev, next } = Astro.props as Props; │
│ -const { Content } = await render(note); │
│ +const { Content, headings } = await render(note); │
│ --- │
│ <ArticleLayout title={note.data.title}> │
│ <TopHeader /> │
│ <ArticleHeader fm={note.data} /> │
│ <DocumentTitle fm={note.data} /> │
│ <ArticleMeta fm={note.data} path={`notes/${note.id}`} /> │
│ + {headings.length >= 3 && <TableOfContents headings={headings} />} │
│ <Prose><Content /></Prose> │
│ <ArticleSeeAlso fm={note.data} /> │
│ <ArticleTags fm={note.data} /> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../src/pages/notes/[slug].astro ───┐
│ diff --git a/ooknet-design/src/pages/notes/[ │
│ slug].astro b/ooknet-design/src/pages/notes/ │
│ [slug].astro │
│ index 0530758..f039af8 100644 │
│ --- a/ooknet-design/src/pages/notes/[slug].a │
│ stro │
│ +++ b/ooknet-design/src/pages/notes/[slug].a │
│ stro │
│ @@ -5,6 +5,7 @@ import TopHeader from "../.. │
│ /components/TopHeader/TopHeader.astro"; │
│ import ArticleHeader from "../../components │
│ /ArticleHeader/ArticleHeader.astro"; │
│ import DocumentTitle from "../../components │
│ /DocumentTitle/DocumentTitle.astro"; │
│ import ArticleMeta from "../../components/A │
│ rticleMeta/ArticleMeta.astro"; │
│ +import TableOfContents from "../../componen │
│ ts/TableOfContents/TableOfContents.astro"; │
│ import ArticleSeeAlso from "../../component │
│ s/ArticleSeeAlso/ArticleSeeAlso.astro"; │
│ import ArticleTags from "../../components/A │
│ rticleTags/ArticleTags.astro"; │
│ import ArticlePagination from "../../compon │
│ ents/ArticlePagination/ArticlePagination.ast │
│ ro"; │
│ @@ -30,13 +31,14 @@ interface Props { │
│ next: PageLink | null; │
│ } │
│ const { note, prev, next } = Astro.props as │
│ Props; │
│ -const { Content } = await render(note); │
│ +const { Content, headings } = await render( │
│ note); │
│ --- │
│ <ArticleLayout title={note.data.title}> │
│ <TopHeader /> │
│ <ArticleHeader fm={note.data} /> │
│ <DocumentTitle fm={note.data} /> │
│ <ArticleMeta fm={note.data} path={`notes/ │
│ ${note.id}`} /> │
│ + {headings.length >= 3 && <TableOfContents │
│ headings={headings} />} │
│ <Prose><Content /></Prose> │
│ <ArticleSeeAlso fm={note.data} /> │
│ <ArticleTags fm={note.data} /> │
└──────────────────────────────────────────────┘
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET