HASH 20f4faeaf1db
DATE 2026-07-19
SUBJECT web: transpose lenient tables at widths they cannot fit
FILES 6 CHANGED
HASH 20f4faeaf1db
DATE 2026-07-19
SUBJECT web: transpose lenient tables at
widths they cannot fit
FILES 6 CHANGED
┌─ .gitignore ───────────────────────────────────────────────────────────────┐
│ diff --git a/.gitignore b/.gitignore │
│ index f7beecb..4634d99 100644 │
│ --- a/.gitignore │
│ +++ b/.gitignore │
│ @@ -1,4 +1,5 @@ │
│ .DS_Store │
│ +node_modules/ │
│ │
│ # machine-local planning docs │
│ .internal/ │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .gitignore ─────────────────────────┐
│ diff --git a/.gitignore b/.gitignore │
│ index f7beecb..4634d99 100644 │
│ --- a/.gitignore │
│ +++ b/.gitignore │
│ @@ -1,4 +1,5 @@ │
│ .DS_Store │
│ +node_modules/ │
│ │
│ # machine-local planning docs │
│ .internal/ │
└──────────────────────────────────────────────┘
┌─ .../.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json ───┐
│ diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/re │
│ sults.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/re │
│ sults.json │
│ deleted file mode 100644 │
│ index ce2dcde..0000000 │
│ --- a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.j │
│ son │
│ +++ /dev/null │
│ @@ -1 +0,0 @@ │
│ -{"version":"4.1.10","results":[[":src/components/GitPath/GitPath.test.ts",{"durat │
│ ion":0,"failed":true}],[":ooknet-design/src/components/GitPath/GitPath.test.ts",{" │
│ duration":2.9634420000000006,"failed":false}]]} │
│ \ No newline at end of file │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...95601890afd80709/results.json ───┐
│ diff --git a/node_modules/.vite/vitest/da39a │
│ 3ee5e6b4b0d3255bfef95601890afd80709/results. │
│ json b/node_modules/.vite/vitest/da39a3ee5e6 │
│ b4b0d3255bfef95601890afd80709/results.json │
│ deleted file mode 100644 │
│ index ce2dcde..0000000 │
│ --- a/node_modules/.vite/vitest/da39a3ee5e6b │
│ 4b0d3255bfef95601890afd80709/results.json │
│ +++ /dev/null │
│ @@ -1 +0,0 @@ │
│ -{"version":"4.1.10","results":[[":src/compo │
│ nents/GitPath/GitPath.test.ts",{"duration":0 │
│ ,"failed":true}],[":ooknet-design/src/compon │
│ ents/GitPath/GitPath.test.ts",{"duration":2. │
│ 9634420000000006,"failed":false}]]} │
│ \ No newline at end of file │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/lib/ascii-table.test.ts ────────────────────────────────┐
│ diff --git a/ooknet-design/src/lib/ascii-table.test.ts b/ooknet-design/src/lib/asc │
│ ii-table.test.ts │
│ index 5720cb1..a5e782b 100644 │
│ --- a/ooknet-design/src/lib/ascii-table.test.ts │
│ +++ b/ooknet-design/src/lib/ascii-table.test.ts │
│ @@ -1,5 +1,5 @@ │
│ import { describe, expect, it } from "vitest"; │
│ -import { buildAsciiTable, type Cell } from "./ascii-table"; │
│ +import { buildAsciiTable, buildTransposedTable, type Cell } from "./ascii-table"; │
│ import { len } from "./ascii"; │
│ │
│ const cell = (text: string, align: Cell["align"] = "end"): Cell => ({ text, align │
│ }); │
│ @@ -51,3 +51,37 @@ describe("buildAsciiTable", () => { │
│ expect(widths.size).toBe(1); │
│ }); │
│ }); │
│ + │
│ +describe("buildTransposedTable", () => { │
│ + const rows = [ │
│ + [cell("host"), cell("spec"), cell("role"), cell("description"), cell("archite │
│ cture"), cell("status")], │
│ + [cell("ooksdesk"), cell("7500F / RX5700XT / 32 GB DDR5"), cell("Workstation") │
│ , cell("Primary desktop workstation"), cell("x86_64"), cell("UP")], │
│ + [cell("ooknode"), cell("Linode Nanode"), cell("Server"), cell("VPS for websit │
│ e"), cell("x86_64"), cell("UP")], │
│ + ]; │
│ + │
│ + it("fits any column count at the narrow width", () => { │
│ + const t = buildTransposedTable(rows, 1, 48); │
│ + const widths = new Set(t.split("\n").map(len)); │
│ + expect(widths.size).toBe(1); │
│ + expect([...widths][0]).toBe(48); │
│ + }); │
│ + │
│ + it("keys each row block by the header, separated by dashed rules", () => { │
│ + const t = buildTransposedTable(rows, 1, 48); │
│ + expect(t.match(/│ host /g)?.length).toBe(2); │
│ + expect(t.match(/├╌+┤/g)?.length).toBe(1); │
│ + expect(t).toContain("ooksdesk"); │
│ + expect(t).toContain("Linode Nanode"); │
│ + }); │
│ + │
│ + it("wraps long values within the value column", () => { │
│ + const t = buildTransposedTable(rows, 1, 48); │
│ + expect(t).toMatch(/│ spec\s+7500F \/ RX5700XT \//); │
│ + }); │
│ + │
│ + it("hard-splits unbreakable cells instead of overflowing", () => { │
│ + const long = [[cell("k")], [cell("x".repeat(120))]]; │
│ + const t = buildTransposedTable(long, 1, 30); │
│ + for (const line of t.split("\n")) expect(len(line)).toBe(30); │
│ + }); │
│ +}); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...n/src/lib/ascii-table.test.ts ───┐
│ diff --git a/ooknet-design/src/lib/ascii-tab │
│ le.test.ts b/ooknet-design/src/lib/ascii-tab │
│ le.test.ts │
│ index 5720cb1..a5e782b 100644 │
│ --- a/ooknet-design/src/lib/ascii-table.test │
│ .ts │
│ +++ b/ooknet-design/src/lib/ascii-table.test │
│ .ts │
│ @@ -1,5 +1,5 @@ │
│ import { describe, expect, it } from "vites │
│ t"; │
│ -import { buildAsciiTable, type Cell } from │
│ "./ascii-table"; │
│ +import { buildAsciiTable, buildTransposedTa │
│ ble, type Cell } from "./ascii-table"; │
│ import { len } from "./ascii"; │
│ │
│ const cell = (text: string, align: Cell["al │
│ ign"] = "end"): Cell => ({ text, align }); │
│ @@ -51,3 +51,37 @@ describe("buildAsciiTable │
│ ", () => { │
│ expect(widths.size).toBe(1); │
│ }); │
│ }); │
│ + │
│ +describe("buildTransposedTable", () => { │
│ + const rows = [ │
│ + [cell("host"), cell("spec"), cell("role │
│ "), cell("description"), cell("architecture" │
│ ), cell("status")], │
│ + [cell("ooksdesk"), cell("7500F / RX5700 │
│ XT / 32 GB DDR5"), cell("Workstation"), cell │
│ ("Primary desktop workstation"), cell("x86_6 │
│ 4"), cell("UP")], │
│ + [cell("ooknode"), cell("Linode Nanode") │
│ , cell("Server"), cell("VPS for website"), c │
│ ell("x86_64"), cell("UP")], │
│ + ]; │
│ + │
│ + it("fits any column count at the narrow w │
│ idth", () => { │
│ + const t = buildTransposedTable(rows, 1, │
│ 48); │
│ + const widths = new Set(t.split("\n").ma │
│ p(len)); │
│ + expect(widths.size).toBe(1); │
│ + expect([...widths][0]).toBe(48); │
│ + }); │
│ + │
│ + it("keys each row block by the header, se │
│ parated by dashed rules", () => { │
│ + const t = buildTransposedTable(rows, 1, │
│ 48); │
│ + expect(t.match(/│ host /g)?.length).toB │
│ e(2); │
│ + expect(t.match(/├╌+┤/g)?.length).toBe(1 │
│ ); │
│ + expect(t).toContain("ooksdesk"); │
│ + expect(t).toContain("Linode Nanode"); │
│ + }); │
│ + │
│ + it("wraps long values within the value co │
│ lumn", () => { │
│ + const t = buildTransposedTable(rows, 1, │
│ 48); │
│ + expect(t).toMatch(/│ spec\s+7500F \/ RX │
│ 5700XT \//); │
│ + }); │
│ + │
│ + it("hard-splits unbreakable cells instead │
│ of overflowing", () => { │
│ + const long = [[cell("k")], [cell("x".re │
│ peat(120))]]; │
│ + const t = buildTransposedTable(long, 1, │
│ 30); │
│ + for (const line of t.split("\n")) expec │
│ t(len(line)).toBe(30); │
│ + }); │
│ +}); │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/lib/ascii-table.ts ─────────────────────────────────────┐
│ diff --git a/ooknet-design/src/lib/ascii-table.ts b/ooknet-design/src/lib/ascii-ta │
│ ble.ts │
│ index c287a06..05f60ce 100644 │
│ --- a/ooknet-design/src/lib/ascii-table.ts │
│ +++ b/ooknet-design/src/lib/ascii-table.ts │
│ @@ -1,6 +1,6 @@ │
│ // Data-driven ASCII table shared by the markdown transform and the │
│ // Table component. │
│ -import { G, checkGrid, len, pad, wrap, type PadSide } from "./ascii"; │
│ +import { G, checkGrid, len, pad, wrap, wrapHard, type PadSide } from "./ascii"; │
│ │
│ export type Cell = { text: string; align: PadSide }; │
│ │
│ @@ -47,3 +47,27 @@ export function buildAsciiTable(rows: Cell[][], headerRows: num │
│ ber, width: numbe │
│ out.push(edge(G.BL, G.BJ, G.BR)); │
│ return checkGrid(out.join("\n"), width, "table"); │
│ } │
│ + │
│ +// A table too wide for its frame transposed: each body row becomes a │
│ +// key/value block keyed by the header row, so any column count fits. │
│ +export function buildTransposedTable(rows: Cell[][], headerRows: number, width: n │
│ umber): string { │
│ + const keys = rows[headerRows - 1].map((c) => c.text); │
│ + const body = rows.slice(headerRows); │
│ + const inner = width - 4; │
│ + const keyW = Math.min(Math.max(1, ...keys.map(len)), Math.max(1, Math.floor((in │
│ ner - 2) / 2))); │
│ + const valW = inner - keyW - 2; │
│ + │
│ + const out: string[] = [`${G.TL}${G.H.repeat(width - 2)}${G.TR}`]; │
│ + body.forEach((row, ri) => { │
│ + if (ri) out.push(`${G.LJ}${G.HD.repeat(width - 2)}${G.RJ}`); │
│ + keys.forEach((key, i) => { │
│ + const kLines = wrapHard(key, keyW).split("\n"); │
│ + const vLines = wrapHard(row[i]?.text ?? "", valW).split("\n"); │
│ + for (let j = 0; j < Math.max(kLines.length, vLines.length); j++) { │
│ + out.push(`${G.V} ${pad(kLines[j] ?? "", keyW)} ${pad(vLines[j] ?? "", va │
│ lW)} ${G.V}`); │
│ + } │
│ + }); │
│ + }); │
│ + out.push(`${G.BL}${G.H.repeat(width - 2)}${G.BR}`); │
│ + return checkGrid(out.join("\n"), width, "transposed table"); │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...design/src/lib/ascii-table.ts ───┐
│ diff --git a/ooknet-design/src/lib/ascii-tab │
│ le.ts b/ooknet-design/src/lib/ascii-table.ts │
│ index c287a06..05f60ce 100644 │
│ --- a/ooknet-design/src/lib/ascii-table.ts │
│ +++ b/ooknet-design/src/lib/ascii-table.ts │
│ @@ -1,6 +1,6 @@ │
│ // Data-driven ASCII table shared by the ma │
│ rkdown transform and the │
│ // Table component. │
│ -import { G, checkGrid, len, pad, wrap, type │
│ PadSide } from "./ascii"; │
│ +import { G, checkGrid, len, pad, wrap, wrap │
│ Hard, type PadSide } from "./ascii"; │
│ │
│ export type Cell = { text: string; align: P │
│ adSide }; │
│ │
│ @@ -47,3 +47,27 @@ export function buildAsci │
│ iTable(rows: Cell[][], headerRows: number, w │
│ idth: numbe │
│ out.push(edge(G.BL, G.BJ, G.BR)); │
│ return checkGrid(out.join("\n"), width, " │
│ table"); │
│ } │
│ + │
│ +// A table too wide for its frame transpose │
│ d: each body row becomes a │
│ +// key/value block keyed by the header row, │
│ so any column count fits. │
│ +export function buildTransposedTable(rows: │
│ Cell[][], headerRows: number, width: number) │
│ : string { │
│ + const keys = rows[headerRows - 1].map((c) │
│ => c.text); │
│ + const body = rows.slice(headerRows); │
│ + const inner = width - 4; │
│ + const keyW = Math.min(Math.max(1, ...keys │
│ .map(len)), Math.max(1, Math.floor((inner - │
│ 2) / 2))); │
│ + const valW = inner - keyW - 2; │
│ + │
│ + const out: string[] = [`${G.TL}${G.H.repe │
│ at(width - 2)}${G.TR}`]; │
│ + body.forEach((row, ri) => { │
│ + if (ri) out.push(`${G.LJ}${G.HD.repeat( │
│ width - 2)}${G.RJ}`); │
│ + keys.forEach((key, i) => { │
│ + const kLines = wrapHard(key, keyW).sp │
│ lit("\n"); │
│ + const vLines = wrapHard(row[i]?.text │
│ ?? "", valW).split("\n"); │
│ + for (let j = 0; j < Math.max(kLines.l │
│ ength, vLines.length); j++) { │
│ + out.push(`${G.V} ${pad(kLines[j] ?? │
│ "", keyW)} ${pad(vLines[j] ?? "", valW)} $ │
│ {G.V}`); │
│ + } │
│ + }); │
│ + }); │
│ + out.push(`${G.BL}${G.H.repeat(width - 2)} │
│ ${G.BR}`); │
│ + return checkGrid(out.join("\n"), width, " │
│ transposed table"); │
│ +} │
└──────────────────────────────────────────────┘
┌─ ooknet-design/src/lib/markdown.test.ts ───────────────────────────────────┐
│ diff --git a/ooknet-design/src/lib/markdown.test.ts b/ooknet-design/src/lib/markdo │
│ wn.test.ts │
│ index 37f44c1..c6d8c05 100644 │
│ --- a/ooknet-design/src/lib/markdown.test.ts │
│ +++ b/ooknet-design/src/lib/markdown.test.ts │
│ @@ -29,14 +29,13 @@ describe("renderMarkdown", () => { │
│ │
│ it("degrades a too-wide table per width, keeps the rest rich", () => { │
│ // 12 short columns fit the wide frame but not the narrow one: │
│ - // desktop keeps the rich table, mobile degrades to a code frame │
│ + // desktop keeps the rich table, mobile transposes to k/v blocks │
│ const html = renderMarkdown(wideTable); │
│ expect(html).toContain("head-rule"); │
│ expect(html).toMatch(/table-frame frame-wide/); │
│ - expect(html).toMatch(/code-block frame-narrow/); │
│ - expect(html).toContain(" TABLE "); │
│ + expect(html).toMatch(/table-frame frame-narrow/); │
│ + expect(html).not.toContain("code-block"); │
│ expect(html).toContain("sr-only"); │
│ - expect(html).toContain("c1 | c2"); │
│ }); │
│ │
│ it("degrades an oversized diagram fence to a code frame", () => { │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...sign/src/lib/markdown.test.ts ───┐
│ diff --git a/ooknet-design/src/lib/markdown. │
│ test.ts b/ooknet-design/src/lib/markdown.tes │
│ t.ts │
│ index 37f44c1..c6d8c05 100644 │
│ --- a/ooknet-design/src/lib/markdown.test.ts │
│ +++ b/ooknet-design/src/lib/markdown.test.ts │
│ @@ -29,14 +29,13 @@ describe("renderMarkdown │
│ ", () => { │
│ │
│ it("degrades a too-wide table per width, │
│ keeps the rest rich", () => { │
│ // 12 short columns fit the wide frame │
│ but not the narrow one: │
│ - // desktop keeps the rich table, mobile │
│ degrades to a code frame │
│ + // desktop keeps the rich table, mobile │
│ transposes to k/v blocks │
│ const html = renderMarkdown(wideTable); │
│ expect(html).toContain("head-rule"); │
│ expect(html).toMatch(/table-frame frame │
│ -wide/); │
│ - expect(html).toMatch(/code-block frame- │
│ narrow/); │
│ - expect(html).toContain(" TABLE "); │
│ + expect(html).toMatch(/table-frame frame │
│ -narrow/); │
│ + expect(html).not.toContain("code-block" │
│ ); │
│ expect(html).toContain("sr-only"); │
│ - expect(html).toContain("c1 | c2"); │
│ }); │
│ │
│ it("degrades an oversized diagram fence t │
│ o a code frame", () => { │
└──────────────────────────────────────────────┘
┌─ 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 620d892..087f465 100644 │
│ --- a/ooknet-design/src/lib/rehype-ascii.ts │
│ +++ b/ooknet-design/src/lib/rehype-ascii.ts │
│ @@ -5,7 +5,7 @@ │
│ // variants in the same tree; CSS toggles visibility per viewport. │
│ import type { Root, Element, ElementContent, RootContent, Properties } from "hast │
│ "; │
│ import { G, checkGrid, len, wrap, type PadSide } from "./ascii"; │
│ -import { buildAsciiTable, type Cell } from "./ascii-table"; │
│ +import { buildAsciiTable, buildTransposedTable, type Cell } from "./ascii-table"; │
│ import { FRAME_W, MOBILE_FRAME_W } from "./config"; │
│ import { mermaidToAscii } from "./mermaid"; │
│ │
│ @@ -307,15 +307,21 @@ export function rehypeAscii(opts: { lenient?: boolean } = {} │
│ ) { │
│ if (!rows.length) return; │
│ │
│ // A table with too many columns for the frame defeats the │
│ - // grid; lenient mode degrades just this table to a code frame │
│ - // whose rows hard-wrap, so it can't fail. Per width, so a │
│ + // grid; lenient mode degrades just this table, per width, so a │
│ // table that fits the wide frame but not the narrow one stays │
│ - // rich on desktop. │
│ + // rich on desktop. Degrade order: transposed key/value blocks │
│ + // (needs a header row), then the rows as a code frame whose │
│ + // lines hard-wrap, so it can't fail. │
│ const preFor = (w: number, cls: string): Element => { │
│ try { │
│ return makeTablePre(buildAsciiTable(rows, headerRows, w), cls); │
│ } catch (e) { │
│ if (!lenient) throw e; │
│ + if (headerRows) { │
│ + try { │
│ + return makeTablePre(buildTransposedTable(rows, headerRows, w), cl │
│ s); │
│ + } catch {} │
│ + } │
│ const text = rows.map((r) => r.map((c) => c.text).join(" | ")).join(" │
│ \n"); │
│ return makeCodePre(text, "table", w, cls); │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...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 620d892..087f465 100644 │
│ --- a/ooknet-design/src/lib/rehype-ascii.ts │
│ +++ b/ooknet-design/src/lib/rehype-ascii.ts │
│ @@ -5,7 +5,7 @@ │
│ // variants in the same tree; CSS toggles v │
│ isibility per viewport. │
│ import type { Root, Element, ElementContent │
│ , RootContent, Properties } from "hast"; │
│ import { G, checkGrid, len, wrap, type PadS │
│ ide } from "./ascii"; │
│ -import { buildAsciiTable, type Cell } from │
│ "./ascii-table"; │
│ +import { buildAsciiTable, buildTransposedTa │
│ ble, type Cell } from "./ascii-table"; │
│ import { FRAME_W, MOBILE_FRAME_W } from "./ │
│ config"; │
│ import { mermaidToAscii } from "./mermaid"; │
│ │
│ @@ -307,15 +307,21 @@ export function rehype │
│ Ascii(opts: { lenient?: boolean } = {}) { │
│ if (!rows.length) return; │
│ │
│ // A table with too many columns fo │
│ r the frame defeats the │
│ - // grid; lenient mode degrades just │
│ this table to a code frame │
│ - // whose rows hard-wrap, so it can' │
│ t fail. Per width, so a │
│ + // grid; lenient mode degrades just │
│ this table, per width, so a │
│ // table that fits the wide frame b │
│ ut not the narrow one stays │
│ - // rich on desktop. │
│ + // rich on desktop. Degrade order: │
│ transposed key/value blocks │
│ + // (needs a header row), then the r │
│ ows as a code frame whose │
│ + // lines hard-wrap, so it can't fai │
│ l. │
│ const preFor = (w: number, cls: str │
│ ing): Element => { │
│ try { │
│ return makeTablePre(buildAsciiT │
│ able(rows, headerRows, w), cls); │
│ } catch (e) { │
│ if (!lenient) throw e; │
│ + if (headerRows) { │
│ + try { │
│ + return makeTablePre(buildTr │
│ ansposedTable(rows, headerRows, w), cls); │
│ + } catch {} │
│ + } │
│ const text = rows.map((r) => r. │
│ map((c) => c.text).join(" | ")).join("\n"); │
│ return makeCodePre(text, "table │
│ ", w, cls); │
│ } │
└──────────────────────────────────────────────┘
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET