Skip to content

Commit c6f3dd4

Browse files
committed
feat(render): size-aware renderMarkdown/renderText — six-tier density ladder
Add { size } to renderMarkdown/renderText: icon (identity line), row (one-line summary, new 4×1 ViewSize tier), small/medium/large/xlarge (budgeted full renders with truncation footers). Omitting size keeps the legacy full render byte-stable. totalItems/totalRows drive footers when payloads arrive pre-sliced.
1 parent 52bbe20 commit c6f3dd4

5 files changed

Lines changed: 347 additions & 37 deletions

File tree

src/render/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
export { renderHTML } from "./html.js";
66
export { renderMarkdown } from "./markdown.js";
7+
export type { RenderOpts } from "./markdown.js";
78
export { renderText } from "./text.js";
89
export { renderA2UI, toA2UIJSONL } from "./a2ui.js";
910
export type {

src/render/markdown.test.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* Size-aware renderMarkdown — the six-tier density contract.
3+
*
4+
* icon = identity only, row = one line, small…xlarge = budgeted full
5+
* renders, no opts = legacy full render (unchanged output).
6+
*/
7+
8+
import { test, expect, describe } from "bun:test";
9+
import { renderMarkdown } from "./markdown.js";
10+
import { renderText } from "./text.js";
11+
import type { WidgetData, ListWidget, TableWidget, PlanWidget } from "../widgets.js";
12+
13+
const list = (n: number): ListWidget => ({
14+
type: "list",
15+
title: "Inbox",
16+
items: Array.from({ length: n }, (_, i) => ({
17+
title: `Mail ${i + 1}`,
18+
subtitle: `sender${i + 1}@example.com`,
19+
})),
20+
});
21+
22+
const table = (n: number): TableWidget => ({
23+
type: "table",
24+
title: "Orders",
25+
columns: ["id", "amount"],
26+
rows: Array.from({ length: n }, (_, i) => ({ id: `o${i + 1}`, amount: i * 10 })),
27+
});
28+
29+
const plan: PlanWidget = {
30+
type: "plan",
31+
title: "Deploy",
32+
steps: [
33+
{ label: "build", status: "completed" },
34+
{ label: "test", status: "in_progress" },
35+
{ label: "ship", status: "pending" },
36+
],
37+
};
38+
39+
describe("legacy (no opts) is unchanged", () => {
40+
test("list caps at 30 with more-footer", () => {
41+
const md = renderMarkdown(list(40));
42+
expect(md).toContain("**Mail 30**");
43+
expect(md).not.toContain("**Mail 31**");
44+
expect(md).toContain("_… 10 more_");
45+
});
46+
47+
test("document truncates at 600 chars", () => {
48+
const md = renderMarkdown({ type: "document", title: "Doc", body: "x".repeat(700) });
49+
expect(md).toContain("x".repeat(600) + "…");
50+
});
51+
});
52+
53+
describe("icon: identity only", () => {
54+
test("list → title + count, no items", () => {
55+
const md = renderMarkdown(list(12), { size: "icon" });
56+
expect(md).toBe("**Inbox** · 12 items");
57+
});
58+
59+
test("plan → progress", () => {
60+
expect(renderMarkdown(plan, { size: "icon" })).toBe("**Deploy** · 1/3 done");
61+
});
62+
63+
test("status → state + message", () => {
64+
const md = renderMarkdown({ type: "status", state: "ok", message: "live" }, { size: "icon" });
65+
expect(md).toBe("✓ **live**");
66+
});
67+
});
68+
69+
describe("row: one line", () => {
70+
test("list → count + first titles, single line", () => {
71+
const md = renderMarkdown(list(12), { size: "row" });
72+
expect(md).toContain("12 items");
73+
expect(md).toContain("Mail 1");
74+
expect(md).not.toContain("\n");
75+
expect(md.length).toBeLessThanOrEqual(161); // 160 + ellipsis
76+
});
77+
78+
test("table → dimensions, no rows", () => {
79+
const md = renderMarkdown(table(45), { size: "row" });
80+
expect(md).toBe("**Orders** · 45 rows × 2 cols");
81+
});
82+
83+
test("plan → progress + current step", () => {
84+
const md = renderMarkdown(plan, { size: "row" });
85+
expect(md).toBe("**Deploy** · 1/3 done — current: test");
86+
});
87+
88+
test("calendar → next event", () => {
89+
const md = renderMarkdown({
90+
type: "calendar", title: "Week",
91+
events: [{ title: "Standup", startsAt: "2026-06-12T09:00:00Z" }],
92+
}, { size: "row" });
93+
expect(md).toContain("next:");
94+
expect(md).toContain("Standup");
95+
});
96+
});
97+
98+
describe("small…xlarge budgets", () => {
99+
test("small list shows 4", () => {
100+
const md = renderMarkdown(list(12), { size: "small" });
101+
expect(md).toContain("**Mail 4**");
102+
expect(md).not.toContain("**Mail 5**");
103+
expect(md).toContain("_… 8 more_");
104+
});
105+
106+
test("medium table shows 6 rows", () => {
107+
const md = renderMarkdown(table(20), { size: "medium" });
108+
expect(md).toContain("| o6 |");
109+
expect(md).not.toContain("| o7 |");
110+
expect(md).toContain("_… 14 more rows_");
111+
});
112+
113+
test("xlarge list shows 30", () => {
114+
const md = renderMarkdown(list(40), { size: "xlarge" });
115+
expect(md).toContain("**Mail 30**");
116+
expect(md).toContain("_… 10 more_");
117+
});
118+
119+
test("totalItems drives the footer when items are pre-sliced", () => {
120+
const w = { ...list(8), totalItems: 100 };
121+
const md = renderMarkdown(w, { size: "medium" });
122+
expect(md).toContain("_… 92 more_");
123+
});
124+
125+
test("stack threads budget to children", () => {
126+
const w: WidgetData = { type: "stack", header: { title: "Wrap" }, body: [list(12)] };
127+
const md = renderMarkdown(w, { size: "small" });
128+
expect(md).toContain("**Mail 4**");
129+
expect(md).not.toContain("**Mail 5**");
130+
});
131+
});
132+
133+
describe("renderText passes size through", () => {
134+
test("row stays one line, markdown stripped", () => {
135+
const txt = renderText(list(12), { size: "row" });
136+
expect(txt).not.toContain("**");
137+
expect(txt).toContain("12 items");
138+
});
139+
});
140+
141+
describe("every type renders at every size without throwing", () => {
142+
const samples: WidgetData[] = [
143+
{ type: "icon", glyph: "<svg/>", color: "blue", label: "App", badge: 3 },
144+
{ type: "stack", header: { title: "S" }, body: [list(2)] },
145+
list(3),
146+
table(3),
147+
{ type: "metric", label: "MRR", value: 100, unit: "€", trend: "up", trendValue: "+5%" },
148+
{ type: "metric_grid", metrics: [{ type: "metric", label: "A", value: 1 }, { type: "metric", label: "B", value: 2 }] },
149+
{ type: "key_value", title: "KV", pairs: [{ key: "k", value: "v" }] },
150+
{ type: "status", state: "warn", message: "degraded", details: [{ key: "region", value: "eu" }] },
151+
{ type: "document", title: "D", body: "hello\nworld" },
152+
{ type: "calendar", title: "C", events: [{ title: "E", startsAt: "2026-06-12T09:00:00Z" }] },
153+
plan,
154+
{ type: "empty", message: "nothing here" },
155+
{ type: "model_3d", uri: "https://x/m.glb", format: "glb", name: "Part" },
156+
];
157+
const sizes = ["icon", "row", "small", "medium", "large", "xlarge", undefined] as const;
158+
159+
for (const w of samples) {
160+
for (const size of sizes) {
161+
test(`${w.type} @ ${size ?? "legacy"}`, () => {
162+
const md = renderMarkdown(w, size ? { size } : undefined);
163+
expect(typeof md).toBe("string");
164+
expect(md.length).toBeGreaterThan(0);
165+
if (size === "row") expect(md).not.toContain("\n");
166+
});
167+
}
168+
}
169+
});

0 commit comments

Comments
 (0)