HASH 4e4b8a4aaefe
DATE 2026-07-17
SUBJECT web: tabs, breadcrumbs, and filed-path search
FILES 13 CHANGED
HASH 4e4b8a4aaefe
DATE 2026-07-17
SUBJECT web: tabs, breadcrumbs, and filed-path
search
FILES 13 CHANGED
┌─ ooknet-design/src/components/ArticleMeta/ArticleMeta.astro ───────────────┐
│ diff --git a/ooknet-design/src/components/ArticleMeta/ArticleMeta.astro b/ooknet-d │
│ esign/src/components/ArticleMeta/ArticleMeta.astro │
│ index f5dcb9e..ac3c926 100644 │
│ --- a/ooknet-design/src/components/ArticleMeta/ArticleMeta.astro │
│ +++ b/ooknet-design/src/components/ArticleMeta/ArticleMeta.astro │
│ @@ -1,6 +1,6 @@ │
│ --- │
│ import Frame from "../Frame/Frame.astro"; │
│ import { buildMeta, type Props } from "./ArticleMeta"; │
│ -const { fm, path } = Astro.props as Props; │
│ +const { fm } = Astro.props as Props; │
│ --- │
│ -<Frame build={(w) => buildMeta(w, fm, path)} /> │
│ +<Frame build={(w) => buildMeta(w, fm)} /> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ArticleMeta/ArticleMeta.astro ───┐
│ diff --git a/ooknet-design/src/components/Ar │
│ ticleMeta/ArticleMeta.astro b/ooknet-design/ │
│ src/components/ArticleMeta/ArticleMeta.astro │
│ index f5dcb9e..ac3c926 100644 │
│ --- a/ooknet-design/src/components/ArticleMe │
│ ta/ArticleMeta.astro │
│ +++ b/ooknet-design/src/components/ArticleMe │
│ ta/ArticleMeta.astro │
│ @@ -1,6 +1,6 @@ │
│ --- │
│ import Frame from "../Frame/Frame.astro"; │
│ import { buildMeta, type Props } from "./Ar │
│ ticleMeta"; │
│ -const { fm, path } = Astro.props as Props; │
│ +const { fm } = Astro.props as Props; │
│ --- │
│ -<Frame build={(w) => buildMeta(w, fm, path) │
│ } /> │
│ +<Frame build={(w) => buildMeta(w, fm)} /> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/ArticleMeta/ArticleMeta.ts ──────────────────┐
│ diff --git a/ooknet-design/src/components/ArticleMeta/ArticleMeta.ts b/ooknet-desi │
│ gn/src/components/ArticleMeta/ArticleMeta.ts │
│ index 8f60dc9..acfeef8 100644 │
│ --- a/ooknet-design/src/components/ArticleMeta/ArticleMeta.ts │
│ +++ b/ooknet-design/src/components/ArticleMeta/ArticleMeta.ts │
│ @@ -4,28 +4,14 @@ import type { NoteFrontmatter } from "../../lib/types"; │
│ export interface Props { │
│ width?: number; │
│ fm: NoteFrontmatter; │
│ - path: string; │
│ } │
│ │
│ -// The path can exceed the narrow frame; char-wrap it under the label. │
│ -function filedUnder(path: string, width: number): string[] { │
│ - const label = "[FILED UNDER]: "; │
│ - const avail = Math.max(1, width - len(label)); │
│ - const cps = [...`/${path}`]; │
│ - const out = [label + cps.slice(0, avail).join("")]; │
│ - for (let i = avail; i < cps.length; i += avail) { │
│ - out.push(" ".repeat(len(label)) + cps.slice(i, i + avail).join("")); │
│ - } │
│ - return out; │
│ -} │
│ - │
│ -export function buildMeta(width: number, fm: NoteFrontmatter, path: string): stri │
│ ng { │
│ +export function buildMeta(width: number, fm: NoteFrontmatter): string { │
│ // Below ~60 cells the JSON-style META block can't fit (the tags array │
│ // alone is ~40 chars), so narrow frames drop it. │
│ const compact = width < 60; │
│ │
│ const lines: string[] = [ │
│ - ...filedUnder(path, width), │
│ rule(width), │
│ "", │
│ kv("AUTHOR", fm.authors.join(", ") || "UNATTRIBUTED", 12, width), │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ts/ArticleMeta/ArticleMeta.ts ───┐
│ diff --git a/ooknet-design/src/components/Ar │
│ ticleMeta/ArticleMeta.ts b/ooknet-design/src │
│ /components/ArticleMeta/ArticleMeta.ts │
│ index 8f60dc9..acfeef8 100644 │
│ --- a/ooknet-design/src/components/ArticleMe │
│ ta/ArticleMeta.ts │
│ +++ b/ooknet-design/src/components/ArticleMe │
│ ta/ArticleMeta.ts │
│ @@ -4,28 +4,14 @@ import type { NoteFrontmat │
│ ter } from "../../lib/types"; │
│ export interface Props { │
│ width?: number; │
│ fm: NoteFrontmatter; │
│ - path: string; │
│ } │
│ │
│ -// The path can exceed the narrow frame; ch │
│ ar-wrap it under the label. │
│ -function filedUnder(path: string, width: nu │
│ mber): string[] { │
│ - const label = "[FILED UNDER]: "; │
│ - const avail = Math.max(1, width - len(lab │
│ el)); │
│ - const cps = [...`/${path}`]; │
│ - const out = [label + cps.slice(0, avail). │
│ join("")]; │
│ - for (let i = avail; i < cps.length; i += │
│ avail) { │
│ - out.push(" ".repeat(len(label)) + cps.s │
│ lice(i, i + avail).join("")); │
│ - } │
│ - return out; │
│ -} │
│ - │
│ -export function buildMeta(width: number, fm │
│ : NoteFrontmatter, path: string): string { │
│ +export function buildMeta(width: number, fm │
│ : NoteFrontmatter): string { │
│ // Below ~60 cells the JSON-style META bl │
│ ock can't fit (the tags array │
│ // alone is ~40 chars), so narrow frames │
│ drop it. │
│ const compact = width < 60; │
│ │
│ const lines: string[] = [ │
│ - ...filedUnder(path, width), │
│ rule(width), │
│ "", │
│ kv("AUTHOR", fm.authors.join(", ") | │
│ | "UNATTRIBUTED", 12, width), │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Breadcrumbs/Breadcrumbs.astro ───────────────┐
│ diff --git a/ooknet-design/src/components/Breadcrumbs/Breadcrumbs.astro b/ooknet-d │
│ esign/src/components/Breadcrumbs/Breadcrumbs.astro │
│ new file mode 100644 │
│ index 0000000..e79bc5d │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Breadcrumbs/Breadcrumbs.astro │
│ @@ -0,0 +1,27 @@ │
│ +--- │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import { len } from "../../lib/ascii"; │
│ +import { FRAME_W, MOBILE_FRAME_W } from "../../lib/config"; │
│ +import { CRUMB_LABEL, crumbs, layoutCrumbs, type Crumb, type Props } from "./Brea │
│ dcrumbs"; │
│ +import "./Breadcrumbs.scss"; │
│ + │
│ +const { filed } = Astro.props as Props; │
│ +const items = crumbs(filed); │
│ +const indent = " ".repeat(len(CRUMB_LABEL)); │
│ + │
│ +const variants: [string, Crumb[][]][] = [ │
│ + ["frame-wide", layoutCrumbs(items, FRAME_W)], │
│ + ["frame-narrow", layoutCrumbs(items, MOBILE_FRAME_W)], │
│ +]; │
│ +--- │
│ +{items.length > 0 && ( │
│ + <nav class="crumbs" aria-label="Filed under"> │
│ + {variants.map(([variant, rows]) => │
│ + rows.map((row, i) => ( │
│ + <Pre class={variant}>{i === 0 ? CRUMB_LABEL : indent}{row.map((c) => ( │
│ + <Fragment>/<a class="crumb-link" href={c.href}>{c.label}</a></Fragment> │
│ + ))}</Pre> │
│ + )) │
│ + )} │
│ + </nav> │
│ +)} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...Breadcrumbs/Breadcrumbs.astro ───┐
│ diff --git a/ooknet-design/src/components/Br │
│ eadcrumbs/Breadcrumbs.astro b/ooknet-design/ │
│ src/components/Breadcrumbs/Breadcrumbs.astro │
│ new file mode 100644 │
│ index 0000000..e79bc5d │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Breadcrum │
│ bs/Breadcrumbs.astro │
│ @@ -0,0 +1,27 @@ │
│ +--- │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import { len } from "../../lib/ascii"; │
│ +import { FRAME_W, MOBILE_FRAME_W } from ".. │
│ /../lib/config"; │
│ +import { CRUMB_LABEL, crumbs, layoutCrumbs, │
│ type Crumb, type Props } from "./Breadcrumb │
│ s"; │
│ +import "./Breadcrumbs.scss"; │
│ + │
│ +const { filed } = Astro.props as Props; │
│ +const items = crumbs(filed); │
│ +const indent = " ".repeat(len(CRUMB_LABEL)) │
│ ; │
│ + │
│ +const variants: [string, Crumb[][]][] = [ │
│ + ["frame-wide", layoutCrumbs(items, FRAME_ │
│ W)], │
│ + ["frame-narrow", layoutCrumbs(items, MOBI │
│ LE_FRAME_W)], │
│ +]; │
│ +--- │
│ +{items.length > 0 && ( │
│ + <nav class="crumbs" aria-label="Filed und │
│ er"> │
│ + {variants.map(([variant, rows]) => │
│ + rows.map((row, i) => ( │
│ + <Pre class={variant}>{i === 0 ? CRU │
│ MB_LABEL : indent}{row.map((c) => ( │
│ + <Fragment>/<a class="crumb-link" │
│ href={c.href}>{c.label}</a></Fragment> │
│ + ))}</Pre> │
│ + )) │
│ + )} │
│ + </nav> │
│ +)} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Breadcrumbs/Breadcrumbs.scss ────────────────┐
│ diff --git a/ooknet-design/src/components/Breadcrumbs/Breadcrumbs.scss b/ooknet-de │
│ sign/src/components/Breadcrumbs/Breadcrumbs.scss │
│ new file mode 100644 │
│ index 0000000..b76c1eb │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Breadcrumbs/Breadcrumbs.scss │
│ @@ -0,0 +1,18 @@ │
│ +.crumbs { │
│ + display: block; │
│ +} │
│ + │
│ +.crumb-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; │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../Breadcrumbs/Breadcrumbs.scss ───┐
│ diff --git a/ooknet-design/src/components/Br │
│ eadcrumbs/Breadcrumbs.scss b/ooknet-design/s │
│ rc/components/Breadcrumbs/Breadcrumbs.scss │
│ new file mode 100644 │
│ index 0000000..b76c1eb │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Breadcrum │
│ bs/Breadcrumbs.scss │
│ @@ -0,0 +1,18 @@ │
│ +.crumbs { │
│ + display: block; │
│ +} │
│ + │
│ +.crumb-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; │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Breadcrumbs/Breadcrumbs.test.ts ─────────────┐
│ diff --git a/ooknet-design/src/components/Breadcrumbs/Breadcrumbs.test.ts b/ooknet │
│ -design/src/components/Breadcrumbs/Breadcrumbs.test.ts │
│ new file mode 100644 │
│ index 0000000..151e2a3 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Breadcrumbs/Breadcrumbs.test.ts │
│ @@ -0,0 +1,36 @@ │
│ +import { describe, expect, it } from "vitest"; │
│ +import { CRUMB_LABEL, crumbs, layoutCrumbs } from "./Breadcrumbs"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +describe("crumbs", () => { │
│ + it("splits the filed path into linked segments", () => { │
│ + expect(crumbs("/META/STYLE/SHEET")).toEqual([ │
│ + { label: "META", href: "/?q=meta" }, │
│ + { label: "STYLE", href: "/?q=style" }, │
│ + { label: "SHEET", href: "/?q=sheet" }, │
│ + ]); │
│ + }); │
│ + │
│ + it("ignores empty segments", () => { │
│ + expect(crumbs("//INFO/")).toHaveLength(1); │
│ + }); │
│ +}); │
│ + │
│ +describe("layoutCrumbs", () => { │
│ + const rowLen = (row: { label: string }[]) => │
│ + row.reduce((s, c) => s + 1 + len(c.label), 0); │
│ + │
│ + it("keeps a short path on one row", () => { │
│ + expect(layoutCrumbs(crumbs("/META/STYLE/SHEET"), 48)).toHaveLength(1); │
│ + }); │
│ + │
│ + it("wraps at segment boundaries within the width", () => { │
│ + const items = crumbs("/INFO/GRAPH/ENTROPY/BOUND/SPARSE/WINDOWS/PERF"); │
│ + const rows = layoutCrumbs(items, 48); │
│ + expect(rows.length).toBeGreaterThan(1); │
│ + for (const row of rows) { │
│ + expect(rowLen(row)).toBeLessThanOrEqual(48 - len(CRUMB_LABEL)); │
│ + } │
│ + expect(rows.flat()).toEqual(items); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...eadcrumbs/Breadcrumbs.test.ts ───┐
│ diff --git a/ooknet-design/src/components/Br │
│ eadcrumbs/Breadcrumbs.test.ts b/ooknet-desig │
│ n/src/components/Breadcrumbs/Breadcrumbs.tes │
│ t.ts │
│ new file mode 100644 │
│ index 0000000..151e2a3 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Breadcrum │
│ bs/Breadcrumbs.test.ts │
│ @@ -0,0 +1,36 @@ │
│ +import { describe, expect, it } from "vites │
│ t"; │
│ +import { CRUMB_LABEL, crumbs, layoutCrumbs │
│ } from "./Breadcrumbs"; │
│ +import { len } from "../../lib/ascii"; │
│ + │
│ +describe("crumbs", () => { │
│ + it("splits the filed path into linked seg │
│ ments", () => { │
│ + expect(crumbs("/META/STYLE/SHEET")).toE │
│ qual([ │
│ + { label: "META", href: "/?q=meta" }, │
│ + { label: "STYLE", href: "/?q=style" } │
│ , │
│ + { label: "SHEET", href: "/?q=sheet" } │
│ , │
│ + ]); │
│ + }); │
│ + │
│ + it("ignores empty segments", () => { │
│ + expect(crumbs("//INFO/")).toHaveLength( │
│ 1); │
│ + }); │
│ +}); │
│ + │
│ +describe("layoutCrumbs", () => { │
│ + const rowLen = (row: { label: string }[]) │
│ => │
│ + row.reduce((s, c) => s + 1 + len(c.labe │
│ l), 0); │
│ + │
│ + it("keeps a short path on one row", () => │
│ { │
│ + expect(layoutCrumbs(crumbs("/META/STYLE │
│ /SHEET"), 48)).toHaveLength(1); │
│ + }); │
│ + │
│ + it("wraps at segment boundaries within th │
│ e width", () => { │
│ + const items = crumbs("/INFO/GRAPH/ENTRO │
│ PY/BOUND/SPARSE/WINDOWS/PERF"); │
│ + const rows = layoutCrumbs(items, 48); │
│ + expect(rows.length).toBeGreaterThan(1); │
│ + for (const row of rows) { │
│ + expect(rowLen(row)).toBeLessThanOrEqu │
│ al(48 - len(CRUMB_LABEL)); │
│ + } │
│ + expect(rows.flat()).toEqual(items); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Breadcrumbs/Breadcrumbs.ts ──────────────────┐
│ diff --git a/ooknet-design/src/components/Breadcrumbs/Breadcrumbs.ts b/ooknet-desi │
│ gn/src/components/Breadcrumbs/Breadcrumbs.ts │
│ new file mode 100644 │
│ index 0000000..0356e89 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Breadcrumbs/Breadcrumbs.ts │
│ @@ -0,0 +1,43 @@ │
│ +// Linkified [FILED UNDER] taxonomy path. Each segment searches the │
│ +// index (like tag badges); long paths wrap at segment boundaries. │
│ +import { checkGrid, len } from "../../lib/ascii"; │
│ + │
│ +export const CRUMB_LABEL = "[FILED UNDER]: "; │
│ + │
│ +export interface Crumb { │
│ + label: string; │
│ + href: string; │
│ +} │
│ + │
│ +export interface Props { │
│ + filed: string; │
│ +} │
│ + │
│ +export function crumbs(filed: string): Crumb[] { │
│ + return filed │
│ + .split("/") │
│ + .filter(Boolean) │
│ + .map((seg) => ({ │
│ + label: seg, │
│ + href: `/?q=${encodeURIComponent(seg.toLowerCase())}`, │
│ + })); │
│ +} │
│ + │
│ +/** Rows of crumbs fitting `width` after the label indent; every crumb │
│ + * costs "/" + label. */ │
│ +export function layoutCrumbs(items: Crumb[], width: number): Crumb[][] { │
│ + const avail = Math.max(1, width - len(CRUMB_LABEL)); │
│ + const rows: Crumb[][] = [[]]; │
│ + let used = 0; │
│ + for (const c of items) { │
│ + checkGrid(`/${c.label}`, avail, "breadcrumb segment"); │
│ + const w = 1 + len(c.label); │
│ + if (used + w > avail && rows[rows.length - 1].length) { │
│ + rows.push([]); │
│ + used = 0; │
│ + } │
│ + rows[rows.length - 1].push(c); │
│ + used += w; │
│ + } │
│ + return rows; │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ts/Breadcrumbs/Breadcrumbs.ts ───┐
│ diff --git a/ooknet-design/src/components/Br │
│ eadcrumbs/Breadcrumbs.ts b/ooknet-design/src │
│ /components/Breadcrumbs/Breadcrumbs.ts │
│ new file mode 100644 │
│ index 0000000..0356e89 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Breadcrum │
│ bs/Breadcrumbs.ts │
│ @@ -0,0 +1,43 @@ │
│ +// Linkified [FILED UNDER] taxonomy path. E │
│ ach segment searches the │
│ +// index (like tag badges); long paths wrap │
│ at segment boundaries. │
│ +import { checkGrid, len } from "../../lib/a │
│ scii"; │
│ + │
│ +export const CRUMB_LABEL = "[FILED UNDER]: │
│ "; │
│ + │
│ +export interface Crumb { │
│ + label: string; │
│ + href: string; │
│ +} │
│ + │
│ +export interface Props { │
│ + filed: string; │
│ +} │
│ + │
│ +export function crumbs(filed: string): Crum │
│ b[] { │
│ + return filed │
│ + .split("/") │
│ + .filter(Boolean) │
│ + .map((seg) => ({ │
│ + label: seg, │
│ + href: `/?q=${encodeURIComponent(seg.t │
│ oLowerCase())}`, │
│ + })); │
│ +} │
│ + │
│ +/** Rows of crumbs fitting `width` after th │
│ e label indent; every crumb │
│ + * costs "/" + label. */ │
│ +export function layoutCrumbs(items: Crumb[] │
│ , width: number): Crumb[][] { │
│ + const avail = Math.max(1, width - len(CRU │
│ MB_LABEL)); │
│ + const rows: Crumb[][] = [[]]; │
│ + let used = 0; │
│ + for (const c of items) { │
│ + checkGrid(`/${c.label}`, avail, "breadc │
│ rumb segment"); │
│ + const w = 1 + len(c.label); │
│ + if (used + w > avail && rows[rows.lengt │
│ h - 1].length) { │
│ + rows.push([]); │
│ + used = 0; │
│ + } │
│ + rows[rows.length - 1].push(c); │
│ + used += w; │
│ + } │
│ + return rows; │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Tabs/Tabs.astro ─────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Tabs/Tabs.astro b/ooknet-design/src/comp │
│ onents/Tabs/Tabs.astro │
│ new file mode 100644 │
│ index 0000000..a2b38e3 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tabs/Tabs.astro │
│ @@ -0,0 +1,61 @@ │
│ +--- │
│ +// Tab bar over a full-width rule. The bar and panels render once (both │
│ +// are width-independent; panel content dual-renders itself), so active │
│ +// state lives in one place. Panels fill via named slots: tab-0, tab-1... │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import Frame from "../Frame/Frame.astro"; │
│ +import { rule } from "../../lib/ascii"; │
│ +import type { Props } from "./Tabs"; │
│ +import "./Tabs.scss"; │
│ + │
│ +const { labels } = Astro.props as Props; │
│ +// slot[name] must be static, so dynamic panel slots render here. │
│ +const panels = await Promise.all(labels.map((_, i) => Astro.slots.render(`tab-${i │
│ }`))); │
│ +--- │
│ +<div class="tabs"> │
│ + <Pre class="tabs-bar"><span role="tablist">{labels.map((l, i) => ( │
│ + <Fragment>{i > 0 ? " " : ""}<button type="button" role="tab" class="tab-btn" │
│ aria-selected={i === 0 ? "true" : "false"}>[ {l} ]</button></Fragment> │
│ + ))}</span></Pre> │
│ + <Frame build={(w) => rule(w)} /> │
│ + {panels.map((html, i) => ( │
│ + <div class="tabs-panel" role="tabpanel" hidden={i !== 0} set:html={html} /> │
│ + ))} │
│ +</div> │
│ + │
│ +<script> │
│ + function activate(tabs: HTMLElement, idx: number) { │
│ + const btns = Array.from(tabs.querySelectorAll<HTMLButtonElement>(":scope > .t │
│ abs-bar .tab-btn")); │
│ + const panels = Array.from(tabs.querySelectorAll<HTMLElement>(":scope > .tabs- │
│ panel")); │
│ + btns.forEach((b, i) => b.setAttribute("aria-selected", String(i === idx))); │
│ + panels.forEach((p, i) => { p.hidden = i !== idx; }); │
│ + } │
│ + │
│ + function ownIndex(btn: HTMLButtonElement): { tabs: HTMLElement; idx: number } | │
│ null { │
│ + const tabs = btn.closest<HTMLElement>(".tabs"); │
│ + if (!tabs) return null; │
│ + const btns = Array.from(tabs.querySelectorAll(":scope > .tabs-bar .tab-btn")) │
│ ; │
│ + return { tabs, idx: btns.indexOf(btn) }; │
│ + } │
│ + │
│ + document.addEventListener("click", (e) => { │
│ + const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".tab-btn"); │
│ + if (!btn) return; │
│ + const own = ownIndex(btn); │
│ + if (own) activate(own.tabs, own.idx); │
│ + }); │
│ + │
│ + document.addEventListener("keydown", (e) => { │
│ + if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; │
│ + const btn = (e.target as HTMLElement).closest?.<HTMLButtonElement>(".tab-btn" │
│ ); │
│ + if (!btn) return; │
│ + const own = ownIndex(btn); │
│ + if (!own) return; │
│ + const btns = Array.from(own.tabs.querySelectorAll<HTMLButtonElement>(":scope │
│ > .tabs-bar .tab-btn")); │
│ + const next = e.key === "ArrowRight" │
│ + ? (own.idx + 1) % btns.length │
│ + : (own.idx - 1 + btns.length) % btns.length; │
│ + e.preventDefault(); │
│ + btns[next].focus(); │
│ + activate(own.tabs, next); │
│ + }); │
│ +</script> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...rc/components/Tabs/Tabs.astro ───┐
│ diff --git a/ooknet-design/src/components/Ta │
│ bs/Tabs.astro b/ooknet-design/src/components │
│ /Tabs/Tabs.astro │
│ new file mode 100644 │
│ index 0000000..a2b38e3 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tabs/Tabs │
│ .astro │
│ @@ -0,0 +1,61 @@ │
│ +--- │
│ +// Tab bar over a full-width rule. The bar │
│ and panels render once (both │
│ +// are width-independent; panel content dua │
│ l-renders itself), so active │
│ +// state lives in one place. Panels fill vi │
│ a named slots: tab-0, tab-1... │
│ +import Pre from "../Pre/Pre.astro"; │
│ +import Frame from "../Frame/Frame.astro"; │
│ +import { rule } from "../../lib/ascii"; │
│ +import type { Props } from "./Tabs"; │
│ +import "./Tabs.scss"; │
│ + │
│ +const { labels } = Astro.props as Props; │
│ +// slot[name] must be static, so dynamic pa │
│ nel slots render here. │
│ +const panels = await Promise.all(labels.map │
│ ((_, i) => Astro.slots.render(`tab-${i}`))); │
│ +--- │
│ +<div class="tabs"> │
│ + <Pre class="tabs-bar"><span role="tablist │
│ ">{labels.map((l, i) => ( │
│ + <Fragment>{i > 0 ? " " : ""}<button ty │
│ pe="button" role="tab" class="tab-btn" aria- │
│ selected={i === 0 ? "true" : "false"}>[ {l} │
│ ]</button></Fragment> │
│ + ))}</span></Pre> │
│ + <Frame build={(w) => rule(w)} /> │
│ + {panels.map((html, i) => ( │
│ + <div class="tabs-panel" role="tabpanel" │
│ hidden={i !== 0} set:html={html} /> │
│ + ))} │
│ +</div> │
│ + │
│ +<script> │
│ + function activate(tabs: HTMLElement, idx: │
│ number) { │
│ + const btns = Array.from(tabs.querySelec │
│ torAll<HTMLButtonElement>(":scope > .tabs-ba │
│ r .tab-btn")); │
│ + const panels = Array.from(tabs.querySel │
│ ectorAll<HTMLElement>(":scope > .tabs-panel" │
│ )); │
│ + btns.forEach((b, i) => b.setAttribute(" │
│ aria-selected", String(i === idx))); │
│ + panels.forEach((p, i) => { p.hidden = i │
│ !== idx; }); │
│ + } │
│ + │
│ + function ownIndex(btn: HTMLButtonElement) │
│ : { tabs: HTMLElement; idx: number } | null │
│ { │
│ + const tabs = btn.closest<HTMLElement>(" │
│ .tabs"); │
│ + if (!tabs) return null; │
│ + const btns = Array.from(tabs.querySelec │
│ torAll(":scope > .tabs-bar .tab-btn")); │
│ + return { tabs, idx: btns.indexOf(btn) } │
│ ; │
│ + } │
│ + │
│ + document.addEventListener("click", (e) => │
│ { │
│ + const btn = (e.target as HTMLElement).c │
│ losest<HTMLButtonElement>(".tab-btn"); │
│ + if (!btn) return; │
│ + const own = ownIndex(btn); │
│ + if (own) activate(own.tabs, own.idx); │
│ + }); │
│ + │
│ + document.addEventListener("keydown", (e) │
│ => { │
│ + if (e.key !== "ArrowLeft" && e.key !== │
│ "ArrowRight") return; │
│ + const btn = (e.target as HTMLElement).c │
│ losest?.<HTMLButtonElement>(".tab-btn"); │
│ + if (!btn) return; │
│ + const own = ownIndex(btn); │
│ + if (!own) return; │
│ + const btns = Array.from(own.tabs.queryS │
│ electorAll<HTMLButtonElement>(":scope > .tab │
│ s-bar .tab-btn")); │
│ + const next = e.key === "ArrowRight" │
│ + ? (own.idx + 1) % btns.length │
│ + : (own.idx - 1 + btns.length) % btns. │
│ length; │
│ + e.preventDefault(); │
│ + btns[next].focus(); │
│ + activate(own.tabs, next); │
│ + }); │
│ +</script> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Tabs/Tabs.scss ──────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Tabs/Tabs.scss b/ooknet-design/src/compo │
│ nents/Tabs/Tabs.scss │
│ new file mode 100644 │
│ index 0000000..5b6e4c5 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tabs/Tabs.scss │
│ @@ -0,0 +1,36 @@ │
│ +.tabs { │
│ + .tab-btn { │
│ + font: inherit; │
│ + color: var(--ink); │
│ + background: none; │
│ + border: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + cursor: pointer; │
│ + white-space: pre; │
│ + │
│ + &:hover:not([aria-selected="true"]) { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + │
│ + &[aria-selected="true"] { │
│ + font-weight: 700; │
│ + background: var(--ink); │
│ + color: var(--paper); │
│ + } │
│ + } │
│ + │
│ + .tabs-panel { │
│ + margin: 0.6em 0; │
│ + │
│ + &[hidden] { │
│ + display: none; │
│ + } │
│ + } │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...src/components/Tabs/Tabs.scss ───┐
│ diff --git a/ooknet-design/src/components/Ta │
│ bs/Tabs.scss b/ooknet-design/src/components/ │
│ Tabs/Tabs.scss │
│ new file mode 100644 │
│ index 0000000..5b6e4c5 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tabs/Tabs │
│ .scss │
│ @@ -0,0 +1,36 @@ │
│ +.tabs { │
│ + .tab-btn { │
│ + font: inherit; │
│ + color: var(--ink); │
│ + background: none; │
│ + border: 0; │
│ + padding: 0; │
│ + margin: 0; │
│ + cursor: pointer; │
│ + white-space: pre; │
│ + │
│ + &:hover:not([aria-selected="true"]) { │
│ + text-decoration: underline; │
│ + text-underline-offset: 3px; │
│ + } │
│ + │
│ + &:focus-visible { │
│ + outline: 1px dashed var(--ink); │
│ + outline-offset: 1px; │
│ + } │
│ + │
│ + &[aria-selected="true"] { │
│ + font-weight: 700; │
│ + background: var(--ink); │
│ + color: var(--paper); │
│ + } │
│ + } │
│ + │
│ + .tabs-panel { │
│ + margin: 0.6em 0; │
│ + │
│ + &[hidden] { │
│ + display: none; │
│ + } │
│ + } │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/components/Tabs/Tabs.ts ────────────────────────────────┐
│ diff --git a/ooknet-design/src/components/Tabs/Tabs.ts b/ooknet-design/src/compone │
│ nts/Tabs/Tabs.ts │
│ new file mode 100644 │
│ index 0000000..09f42c8 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tabs/Tabs.ts │
│ @@ -0,0 +1,3 @@ │
│ +export interface Props { │
│ + labels: string[]; │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...n/src/components/Tabs/Tabs.ts ───┐
│ diff --git a/ooknet-design/src/components/Ta │
│ bs/Tabs.ts b/ooknet-design/src/components/Ta │
│ bs/Tabs.ts │
│ new file mode 100644 │
│ index 0000000..09f42c8 │
│ --- /dev/null │
│ +++ b/ooknet-design/src/components/Tabs/Tabs │
│ .ts │
│ @@ -0,0 +1,3 @@ │
│ +export interface Props { │
│ + labels: string[]; │
│ +} │
└──────────────────────────────────────────────┘
┌─ 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 1d7ecc2..0a19b5d 100644 │
│ --- a/ooknet-design/src/components/TopHeader/TopHeader.astro │
│ +++ b/ooknet-design/src/components/TopHeader/TopHeader.astro │
│ @@ -41,7 +41,7 @@ const PLACEHOLDER_NARROW = "_".repeat(18); │
│ <script> │
│ import Fuse from "fuse.js"; │
│ │
│ - type Note = { id: string; note: string; title: string; category: string; tags: │
│ string[] }; │
│ + type Note = { id: string; note: string; title: string; category: string; tags: │
│ string[]; filed: string }; │
│ let fuse: Fuse<Note> | null = null; │
│ │
│ async function ensureLoaded() { │
│ @@ -54,6 +54,7 @@ const PLACEHOLDER_NARROW = "_".repeat(18); │
│ { name: "note", weight: 1.5 }, │
│ { name: "category", weight: 1 }, │
│ { name: "tags", weight: 1 }, │
│ + { name: "filed", weight: 1 }, │
│ ], │
│ threshold: 0.4, │
│ ignoreLocation: true, │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...nts/TopHeader/TopHeader.astro ───┐
│ diff --git a/ooknet-design/src/components/To │
│ pHeader/TopHeader.astro b/ooknet-design/src/ │
│ components/TopHeader/TopHeader.astro │
│ index 1d7ecc2..0a19b5d 100644 │
│ --- a/ooknet-design/src/components/TopHeader │
│ /TopHeader.astro │
│ +++ b/ooknet-design/src/components/TopHeader │
│ /TopHeader.astro │
│ @@ -41,7 +41,7 @@ const PLACEHOLDER_NARROW = │
│ "_".repeat(18); │
│ <script> │
│ import Fuse from "fuse.js"; │
│ │
│ - type Note = { id: string; note: string; t │
│ itle: string; category: string; tags: string │
│ [] }; │
│ + type Note = { id: string; note: string; t │
│ itle: string; category: string; tags: string │
│ []; filed: string }; │
│ let fuse: Fuse<Note> | null = null; │
│ │
│ async function ensureLoaded() { │
│ @@ -54,6 +54,7 @@ const PLACEHOLDER_NARROW = │
│ "_".repeat(18); │
│ { name: "note", weight: 1.5 }, │
│ { name: "category", weight: 1 }, │
│ { name: "tags", weight: 1 }, │
│ + { name: "filed", weight: 1 }, │
│ ], │
│ threshold: 0.4, │
│ ignoreLocation: true, │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/pages/components.astro ─────────────────────────────────┐
│ diff --git a/ooknet-design/src/pages/components.astro b/ooknet-design/src/pages/co │
│ mponents.astro │
│ index 6f54486..f2d83e2 100644 │
│ --- a/ooknet-design/src/pages/components.astro │
│ +++ b/ooknet-design/src/pages/components.astro │
│ @@ -17,6 +17,8 @@ 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 Tabs from "../components/Tabs/Tabs.astro"; │
│ +import Breadcrumbs from "../components/Breadcrumbs/Breadcrumbs.astro"; │
│ import Timeline from "../components/Timeline/Timeline.astro"; │
│ import type { TimelineEntry } from "../components/Timeline/Timeline"; │
│ import { rule, ruleD, row2, pad } from "../lib/ascii"; │
│ @@ -156,6 +158,28 @@ const FILED_TREE: TreeNode[] = [ │
│ <Timeline entries={HISTORY} /> │
│ <Pre>{" "}</Pre> │
│ │
│ + <Frame build={section("TABS", "Tabs.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Tabs labels={["NIX", "NPM", "MANUAL"]}> │
│ + <Fragment slot="tab-0"> │
│ + <Pre>{"$ nix develop"}</Pre> │
│ + <Pre>{"$ npm run dev"}</Pre> │
│ + </Fragment> │
│ + <Fragment slot="tab-1"> │
│ + <Pre>{"$ npm install"}</Pre> │
│ + <Pre>{"$ npm run dev"}</Pre> │
│ + </Fragment> │
│ + <Fragment slot="tab-2"> │
│ + <Pre>{"clone the repo and bring your own node >= 22.12"}</Pre> │
│ + </Fragment> │
│ + </Tabs> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("BREADCRUMBS", "Breadcrumbs.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Breadcrumbs filed="/INFO/GRAPH/ENTROPY/BOUND" /> │
│ + <Pre>{" "}</Pre> │
│ + │
│ <Frame build={section("TOAST", "Toast.astro")} /> │
│ <Pre>{" "}</Pre> │
│ <Pre><span id="toast-ok"><Button label="QUEUE" variant="primary" /></span>{" " │
│ }<span id="toast-error"><Button label="JAM" /></span>{" "}<span id="toast-pending │
│ "><Button label="HOLD" /></span></Pre> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...gn/src/pages/components.astro ───┐
│ diff --git a/ooknet-design/src/pages/compone │
│ nts.astro b/ooknet-design/src/pages/componen │
│ ts.astro │
│ index 6f54486..f2d83e2 100644 │
│ --- a/ooknet-design/src/pages/components.ast │
│ ro │
│ +++ b/ooknet-design/src/pages/components.ast │
│ ro │
│ @@ -17,6 +17,8 @@ import Spinner from "../co │
│ mponents/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 Tabs from "../components/Tabs/Tabs.a │
│ stro"; │
│ +import Breadcrumbs from "../components/Brea │
│ dcrumbs/Breadcrumbs.astro"; │
│ import Timeline from "../components/Timelin │
│ e/Timeline.astro"; │
│ import type { TimelineEntry } from "../comp │
│ onents/Timeline/Timeline"; │
│ import { rule, ruleD, row2, pad } from "../ │
│ lib/ascii"; │
│ @@ -156,6 +158,28 @@ const FILED_TREE: TreeN │
│ ode[] = [ │
│ <Timeline entries={HISTORY} /> │
│ <Pre>{" "}</Pre> │
│ │
│ + <Frame build={section("TABS", "Tabs.astro │
│ ")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Tabs labels={["NIX", "NPM", "MANUAL"]}> │
│ + <Fragment slot="tab-0"> │
│ + <Pre>{"$ nix develop"}</Pre> │
│ + <Pre>{"$ npm run dev"}</Pre> │
│ + </Fragment> │
│ + <Fragment slot="tab-1"> │
│ + <Pre>{"$ npm install"}</Pre> │
│ + <Pre>{"$ npm run dev"}</Pre> │
│ + </Fragment> │
│ + <Fragment slot="tab-2"> │
│ + <Pre>{"clone the repo and bring your │
│ own node >= 22.12"}</Pre> │
│ + </Fragment> │
│ + </Tabs> │
│ + <Pre>{" "}</Pre> │
│ + │
│ + <Frame build={section("BREADCRUMBS", "Bre │
│ adcrumbs.astro")} /> │
│ + <Pre>{" "}</Pre> │
│ + <Breadcrumbs filed="/INFO/GRAPH/ENTROPY/B │
│ OUND" /> │
│ + <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> │
└──────────────────────────────────────────────┘
┌─ 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 f039af8..b7c1968 100644 │
│ --- a/ooknet-design/src/pages/notes/[slug].astro │
│ +++ b/ooknet-design/src/pages/notes/[slug].astro │
│ @@ -6,6 +6,7 @@ import ArticleHeader from "../../components/ArticleHeader/ArticleH │
│ eader.astro"; │
│ import DocumentTitle from "../../components/DocumentTitle/DocumentTitle.astro"; │
│ import ArticleMeta from "../../components/ArticleMeta/ArticleMeta.astro"; │
│ import TableOfContents from "../../components/TableOfContents/TableOfContents.ast │
│ ro"; │
│ +import Breadcrumbs from "../../components/Breadcrumbs/Breadcrumbs.astro"; │
│ import ArticleSeeAlso from "../../components/ArticleSeeAlso/ArticleSeeAlso.astro" │
│ ; │
│ import ArticleTags from "../../components/ArticleTags/ArticleTags.astro"; │
│ import ArticlePagination from "../../components/ArticlePagination/ArticlePaginati │
│ on.astro"; │
│ @@ -37,7 +38,8 @@ const { Content, headings } = await render(note); │
│ <TopHeader /> │
│ <ArticleHeader fm={note.data} /> │
│ <DocumentTitle fm={note.data} /> │
│ - <ArticleMeta fm={note.data} path={`notes/${note.id}`} /> │
│ + <Breadcrumbs filed={note.data.filed} /> │
│ + <ArticleMeta fm={note.data} /> │
│ {headings.length >= 3 && <TableOfContents headings={headings} />} │
│ <Prose><Content /></Prose> │
│ <ArticleSeeAlso 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 f039af8..b7c1968 100644 │
│ --- a/ooknet-design/src/pages/notes/[slug].a │
│ stro │
│ +++ b/ooknet-design/src/pages/notes/[slug].a │
│ stro │
│ @@ -6,6 +6,7 @@ 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 Breadcrumbs from "../../components/B │
│ readcrumbs/Breadcrumbs.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"; │
│ @@ -37,7 +38,8 @@ const { Content, headings │
│ } = await render(note); │
│ <TopHeader /> │
│ <ArticleHeader fm={note.data} /> │
│ <DocumentTitle fm={note.data} /> │
│ - <ArticleMeta fm={note.data} path={`notes/ │
│ ${note.id}`} /> │
│ + <Breadcrumbs filed={note.data.filed} /> │
│ + <ArticleMeta fm={note.data} /> │
│ {headings.length >= 3 && <TableOfContents │
│ headings={headings} />} │
│ <Prose><Content /></Prose> │
│ <ArticleSeeAlso fm={note.data} /> │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/pages/search.json.ts ───────────────────────────────────┐
│ diff --git a/ooknet-design/src/pages/search.json.ts b/ooknet-design/src/pages/sear │
│ ch.json.ts │
│ index 5805975..c4bec1c 100644 │
│ --- a/ooknet-design/src/pages/search.json.ts │
│ +++ b/ooknet-design/src/pages/search.json.ts │
│ @@ -11,6 +11,7 @@ export const GET: APIRoute = async () => { │
│ title: n.data.title, │
│ category: n.data.meta.category, │
│ tags: n.data.meta.tags, │
│ + filed: n.data.filed, │
│ })); │
│ return new Response(JSON.stringify(data), { │
│ headers: { "Content-Type": "application/json" }, │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...sign/src/pages/search.json.ts ───┐
│ diff --git a/ooknet-design/src/pages/search. │
│ json.ts b/ooknet-design/src/pages/search.jso │
│ n.ts │
│ index 5805975..c4bec1c 100644 │
│ --- a/ooknet-design/src/pages/search.json.ts │
│ +++ b/ooknet-design/src/pages/search.json.ts │
│ @@ -11,6 +11,7 @@ export const GET: APIRoute │
│ = async () => { │
│ title: n.data.title, │
│ category: n.data.meta.category, │
│ tags: n.data.meta.tags, │
│ + filed: n.data.filed, │
│ })); │
│ return new Response(JSON.stringify(data), │
│ { │
│ headers: { "Content-Type": "application │
│ /json" }, │
└──────────────────────────────────────────────┘
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET