OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
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
 

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/

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/
 

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

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
 

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);
+  });
+});

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);
+  });
+});
 

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");
+}

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");
+}
 

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", () => {

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", () => {
 

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);
           }

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