@company-semantics/contracts 51.3.0 → 52.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/identity/__tests__/people-org-chart.test.ts +75 -0
  3. package/src/identity/people-org-chart.ts +25 -6
  4. package/src/notifications/__tests__/__snapshots__/monospace-budget.test.ts.snap +23 -0
  5. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +309 -259
  6. package/src/notifications/__tests__/monospace-budget.test.ts +75 -0
  7. package/src/notifications/renderers/README.md +8 -4
  8. package/src/notifications/renderers/ascii/README.md +75 -0
  9. package/src/notifications/renderers/ascii/__tests__/README.md +39 -0
  10. package/src/notifications/renderers/ascii/__tests__/layout.test.ts +228 -0
  11. package/src/notifications/renderers/ascii/chat.ts +179 -0
  12. package/src/notifications/renderers/ascii/cta.ts +57 -0
  13. package/src/notifications/renderers/ascii/geometry.ts +112 -0
  14. package/src/notifications/renderers/ascii/index.ts +40 -0
  15. package/src/notifications/renderers/ascii/keyvalue.ts +34 -0
  16. package/src/notifications/renderers/ascii/rule.ts +53 -0
  17. package/src/notifications/renderers/ascii/runs.ts +84 -0
  18. package/src/notifications/renderers/ascii/signature.ts +41 -0
  19. package/src/notifications/renderers/ascii/wrap.ts +96 -0
  20. package/src/notifications/renderers/brand.ts +12 -0
  21. package/src/notifications/renderers/email/chat.ts +62 -146
  22. package/src/notifications/renderers/email/constants.ts +17 -2
  23. package/src/notifications/renderers/email/cta.ts +8 -13
  24. package/src/notifications/renderers/email/render.ts +29 -5
  25. package/src/notifications/renderers/layout.ts +31 -0
  26. package/src/notifications/renderers/slack/README.md +135 -79
  27. package/src/notifications/renderers/slack/__tests__/README.md +3 -2
  28. package/src/notifications/renderers/slack/__tests__/index.test.ts +233 -93
  29. package/src/notifications/renderers/slack/blocks.ts +149 -0
  30. package/src/notifications/renderers/slack/chat.ts +167 -0
  31. package/src/notifications/renderers/slack/cta.ts +69 -0
  32. package/src/notifications/renderers/slack/index.ts +136 -229
  33. package/src/notifications/renderers/slack/message.ts +23 -0
@@ -1,58 +1,43 @@
1
1
  /**
2
2
  * Email's answer to a `chatUnit` — bubbles in HTML, box art in plain text.
3
3
  *
4
- * Moved verbatim from the deleted `src/email/render/blocks` (ADR-CONTRACTS-086), with
5
- * one change: it lays out `ChatUnitItem`s from `../../content` rather than the
6
- * old layer's parallel `ChatItem` vocabulary. The mapping is exact
7
- * `message` a bubble, `callToAction` an embedded button, `continuation` ↔
8
- * the "⋮" dots.
4
+ * The box art and the positioning both moved to `../ascii`: two channels that
5
+ * wrap at different points are two different messages, and Slack draws this
6
+ * same conversation. What stays here is the part that is genuinely email's
7
+ * the three-column `<table>` skeleton, the margin recipes, and the `<hr>`
8
+ * bracket.
9
+ *
10
+ * The HTML and the text are two renderings of ONE plan. `planChatUnit` makes the
11
+ * alignment decisions once and this file walks them; it does not re-derive which
12
+ * side a CTA hangs on, because the day that derivation drifted from Slack's copy
13
+ * of it would be the day the two channels disagreed about the same conversation.
9
14
  *
10
15
  * INVARIANTS:
11
16
  * - Pure. The box art is real output, and every column of it is locked by
12
17
  * `../../__tests__/render-snapshot.test.ts` — a stray space is a visibly
13
18
  * broken email, not a whitespace nit.
14
- * - Both surfaces truncate at the same point — `clampMessage` is the one
15
- * truncation authority, and HTML and plain text both run content through it.
19
+ * - Both surfaces truncate at the same point — `clampMessage` in `../ascii` is
20
+ * the one truncation authority, and HTML and plain text both run content
21
+ * through it.
16
22
  */
17
23
 
18
24
  import type { CallToAction, ChatTurn, ChatUnitItem } from "../../content";
19
-
25
+ import {
26
+ ruleAscii,
27
+ clampMessage,
28
+ flattenLines,
29
+ type MonospaceLine,
30
+ planChatUnit,
31
+ renderBubbleAscii,
32
+ renderChatCtaAscii,
33
+ renderChatDotsAscii,
34
+ } from "../ascii";
35
+
36
+ import { EMAIL_GEOMETRY } from "./constants";
20
37
  import { ctaButton } from "./cta";
21
38
  import { escapeHtml } from "./escape-html";
22
39
  import { styleClass } from "./styles";
23
40
 
24
- /** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
25
- function wrapText(text: string, width: number): string[] {
26
- const lines: string[] = [];
27
- let cur = "";
28
- for (const word of text.split(/\s+/).filter(Boolean)) {
29
- let w = word;
30
- while (w.length > width) {
31
- if (cur) {
32
- lines.push(cur);
33
- cur = "";
34
- }
35
- lines.push(w.slice(0, width));
36
- w = w.slice(width);
37
- }
38
- if (!cur) cur = w;
39
- else if (cur.length + 1 + w.length <= width) cur += ` ${w}`;
40
- else {
41
- lines.push(cur);
42
- cur = w;
43
- }
44
- }
45
- if (cur) lines.push(cur);
46
- return lines.length ? lines : [""];
47
- }
48
-
49
- /** Chat message truncation budget: at most MAX_MESSAGE_LINES lines of MESSAGE_WIDTH chars. */
50
- const MAX_MESSAGE_LINES = 3;
51
- const MESSAGE_WIDTH = 36;
52
- /** Plain-text left gutter (7 cols) reserved for the avatar on both sides, so the
53
- * user box aligns with the assistant box. */
54
- const CHAT_INDENT = " ";
55
-
56
41
  /**
57
42
  * The gap under a chat row: 24px clears the bubbles, 16px hugs whatever the row
58
43
  * is introducing (a CTA, the dots).
@@ -64,25 +49,10 @@ const CHAT_INDENT = " ";
64
49
  */
65
50
  type ChatRow = "chat-row-16" | "chat-row-24";
66
51
 
67
- /** Word-wrap `text`, then clamp to `maxLines`, ellipsizing the last line on overflow. */
68
- function wrapClamped(text: string, width: number, maxLines: number): string[] {
69
- const lines = wrapText(text, width);
70
- if (lines.length <= maxLines) return lines;
71
- const kept = lines.slice(0, maxLines);
72
- const last = kept[maxLines - 1];
73
- kept[maxLines - 1] =
74
- (last.length > width - 3 ? last.slice(0, width - 3).trimEnd() : last) +
75
- "...";
76
- return kept;
77
- }
78
-
79
- /**
80
- * The one truncation authority: clamp a raw message to MAX_MESSAGE_LINES ×
81
- * MESSAGE_WIDTH, ellipsized. Both surfaces of a chat unit run content through
82
- * this, so HTML and plain text truncate at exactly the same point.
83
- */
84
- function clampMessage(text: string): string {
85
- return wrapClamped(text, MESSAGE_WIDTH, MAX_MESSAGE_LINES).join(" ");
52
+ /** One part of the unit, drawn on both surfaces. */
53
+ interface ChatPartRender {
54
+ html: string;
55
+ text: MonospaceLine[];
86
56
  }
87
57
 
88
58
  /** The `<hr>` bracketing a chat unit — 24px toward the bubbles, 12px on the
@@ -98,11 +68,12 @@ function chatRuleHtml(position: "top" | "bottom"): string {
98
68
  * width, so bubbles stay bounded and aligned), a middle cell that right/left-
99
69
  * aligns the bubble, and — for a user `from` — an attribution row below.
100
70
  */
101
- function renderBubble(
102
- turn: ChatTurn,
103
- row: ChatRow,
104
- ): { html: string; text: string[] } {
105
- const clamped = clampMessage(turn.text);
71
+ function renderBubble(turn: ChatTurn, row: ChatRow): ChatPartRender {
72
+ const clamped = clampMessage(
73
+ turn.text,
74
+ EMAIL_GEOMETRY.messageWidth,
75
+ EMAIL_GEOMETRY.maxMessageLines,
76
+ );
106
77
  const isUser = turn.role === "user";
107
78
 
108
79
  // Each side draws its own avatar and hides the other's, which is what reserves
@@ -132,31 +103,9 @@ function renderBubble(
132
103
  </tr>${attributionRow}
133
104
  </table>`;
134
105
 
135
- const border = "─".repeat(MESSAGE_WIDTH + 2);
136
- const lines = wrapText(clamped, MESSAGE_WIDTH);
137
- const body = lines.map(
138
- (line) =>
139
- `${CHAT_INDENT}│ ${isUser ? line.padStart(MESSAGE_WIDTH) : line.padEnd(MESSAGE_WIDTH)} │`,
140
- );
141
- // Avatar beside the last message line (one row up from the bottom border).
142
- const last = body.length - 1;
143
- if (isUser) body[last] += " (•̀_ರ╮)";
144
- else body[last] = `[c_S] ${body[last].slice(CHAT_INDENT.length)}`;
145
-
146
- const box = [
147
- `${CHAT_INDENT}┌${border}┐`,
148
- ...body,
149
- `${CHAT_INDENT}└${border}┘`,
150
- ];
151
- if (isUser && turn.from) {
152
- box.push(turn.from.padStart(CHAT_INDENT.length + MESSAGE_WIDTH + 3));
153
- }
154
- return { html, text: box };
106
+ return { html, text: renderBubbleAscii(turn, EMAIL_GEOMETRY) };
155
107
  }
156
108
 
157
- /** The right-edge column the plain-text CTA/dots align to under a user bubble. */
158
- const CHAT_RIGHT_EDGE = CHAT_INDENT.length + MESSAGE_WIDTH + 4;
159
-
160
109
  /** Centered "⋮" HTML, sized to sit above and centered over a CTA box (they share
161
110
  * the same inline-block, so the dots span exactly the button's width). */
162
111
  function dotsOverCtaHtml(): string {
@@ -174,8 +123,8 @@ function renderChatCta(
174
123
  cta: CallToAction,
175
124
  align: "left" | "right",
176
125
  withDots: boolean,
177
- ): { html: string; text: string[] } {
178
- const { html: btnHtml, text: btnText } = ctaButton(cta, "none");
126
+ ): ChatPartRender {
127
+ const { html: btnHtml } = ctaButton(cta, "none");
179
128
  // Dots + button share one inline-block so the dots center over the button's
180
129
  // exact width regardless of label length.
181
130
  const stack = `<div ${styleClass("cta-stack")}>${withDots ? dotsOverCtaHtml() : ""}${btnHtml}</div>`;
@@ -187,26 +136,10 @@ function renderChatCta(
187
136
  </tr>
188
137
  </table>`;
189
138
 
190
- // Right-align to the message box's right edge (under a user bubble); else the
191
- // left avatar gutter.
192
- const btnLines = btnText
193
- .split("\n")
194
- .map((l) =>
195
- !l
196
- ? l
197
- : align === "right"
198
- ? l.padStart(CHAT_RIGHT_EDGE)
199
- : `${CHAT_INDENT}${l}`,
200
- );
201
- if (!withDots) return { html, text: btnLines };
202
-
203
- // Center "⋮" over the ascii box (its first line spans the full box width).
204
- const boxWidth = btnText.split("\n")[0].length;
205
- const dotsCol =
206
- align === "right"
207
- ? CHAT_RIGHT_EDGE - Math.floor(boxWidth / 2)
208
- : CHAT_INDENT.length + Math.ceil(boxWidth / 2);
209
- return { html, text: ["⋮".padStart(dotsCol), "", ...btnLines] };
139
+ return {
140
+ html,
141
+ text: renderChatCtaAscii(cta, align, withDots, EMAIL_GEOMETRY),
142
+ };
210
143
  }
211
144
 
212
145
  /**
@@ -214,7 +147,7 @@ function renderChatCta(
214
147
  * a `continuation` is NOT immediately followed by a `callToAction` (the common
215
148
  * case folds the dots into the CTA via `renderChatCta`, centered over the box).
216
149
  */
217
- function renderChatDots(): { html: string; text: string[] } {
150
+ function renderChatDots(): ChatPartRender {
218
151
  const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" ${styleClass("chat-row-16")}>
219
152
  <tr>
220
153
  <td ${styleClass("chat-avatar-left-hidden")}>[c_S]</td>
@@ -222,9 +155,7 @@ function renderChatDots(): { html: string; text: string[] } {
222
155
  <td ${styleClass("chat-avatar-right-hidden")}>(•̀_ರ╮)</td>
223
156
  </tr>
224
157
  </table>`;
225
- // Center the "⋮" over the message box (avatar gutter + box width + borders).
226
- const center = Math.round(CHAT_RIGHT_EDGE / 2);
227
- return { html, text: ["⋮".padStart(center)] };
158
+ return { html, text: renderChatDotsAscii(EMAIL_GEOMETRY) };
228
159
  }
229
160
 
230
161
  /**
@@ -238,41 +169,26 @@ export function renderChatUnit(items: ChatUnitItem[]): {
238
169
  html: string;
239
170
  text: string;
240
171
  } {
241
- const parts: { html: string; text: string[] }[] = [];
242
- items.forEach((item, i) => {
243
- if (item.type === "continuation") {
244
- // Dots immediately before a CTA render with it (centered over the box);
245
- // otherwise fall back to channel-centered standalone dots.
246
- if (items[i + 1]?.type !== "callToAction") parts.push(renderChatDots());
247
- return;
248
- }
249
- if (item.type === "callToAction") {
250
- // Mirror the side of the nearest preceding message (skip any dots between),
251
- // so the CTA sits under the bubble it belongs to — right under a user.
252
- let j = i - 1;
253
- while (j >= 0 && items[j].type === "continuation") j--;
254
- const prev = items[j];
255
- const align =
256
- prev?.type === "message" && prev.role === "user" ? "right" : "left";
257
- parts.push(
258
- renderChatCta(item, align, items[i - 1]?.type === "continuation"),
259
- );
260
- return;
172
+ const parts = planChatUnit(items).map((part): ChatPartRender => {
173
+ switch (part.kind) {
174
+ case "dots":
175
+ return renderChatDots();
176
+ case "cta":
177
+ return renderChatCta(part.cta, part.align, part.withDots);
178
+ case "bubble":
179
+ return renderBubble(
180
+ part.turn,
181
+ part.tight ? "chat-row-16" : "chat-row-24",
182
+ );
261
183
  }
262
- // A bubble directly above a CTA or continuation dots gets a tighter 16px
263
- // gap; else 24px.
264
- const next = items[i + 1]?.type;
265
- const row: ChatRow =
266
- next === "callToAction" || next === "continuation"
267
- ? "chat-row-16"
268
- : "chat-row-24";
269
- parts.push(renderBubble(item, row));
270
184
  });
271
185
 
272
- const width = parts
273
- .flatMap((part) => part.text)
274
- .reduce((w, l) => Math.max(w, l.length), 0);
275
- const rule = "_".repeat(width);
186
+ const rule = flattenLines([
187
+ ruleAscii(
188
+ parts.flatMap((part) => part.text),
189
+ EMAIL_GEOMETRY,
190
+ ),
191
+ ]);
276
192
 
277
193
  const html = [
278
194
  chatRuleHtml("top"),
@@ -282,7 +198,7 @@ export function renderChatUnit(items: ChatUnitItem[]): {
282
198
 
283
199
  const text =
284
200
  `${rule}\n\n` +
285
- parts.map((part) => part.text.join("\n")).join("\n\n") +
201
+ parts.map((part) => flattenLines(part.text)).join("\n\n") +
286
202
  `\n${rule}`;
287
203
 
288
204
  return { html, text };
@@ -13,6 +13,9 @@
13
13
  * on every channel and renderers read it from `context.brand`.
14
14
  */
15
15
 
16
+ import { geometryFor } from "../ascii";
17
+ import { MONOSPACE_COLUMNS } from "../layout";
18
+
16
19
  // =============================================================================
17
20
  // Branding
18
21
  // =============================================================================
@@ -21,8 +24,20 @@
21
24
  const MONO_FONT_STACK =
22
25
  "'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace";
23
26
 
24
- /** The `companysemantics.ai` link under every signature. */
25
- export const COMPANY_URL = "https://companysemantics.ai";
27
+ /**
28
+ * Every derived monospace measurement of the plain-text surface.
29
+ *
30
+ * The budget itself is `../layout.ts`'s, not email's: Slack draws the same box
31
+ * art, and a width only one channel honoured would put the two back out of step.
32
+ */
33
+ export const EMAIL_GEOMETRY = geometryFor(MONOSPACE_COLUMNS);
34
+
35
+ /**
36
+ * Re-exported from `../brand.ts`, where it moved when Slack started signing with
37
+ * it too. Kept named here so this channel's modules still import their constants
38
+ * from one place.
39
+ */
40
+ export { COMPANY_URL } from "../brand";
26
41
 
27
42
  // =============================================================================
28
43
  // Styling
@@ -16,13 +16,12 @@
16
16
 
17
17
  import type { CallToAction } from "../../content";
18
18
 
19
- import { CTA_PAD, type Spacing } from "./constants";
19
+ import { flattenLines, renderCtaAscii } from "../ascii";
20
+
21
+ import { CTA_PAD, EMAIL_GEOMETRY, type Spacing } from "./constants";
20
22
  import { escapeHtml } from "./escape-html";
21
23
  import { styleClass } from "./styles";
22
24
 
23
- /** Padding columns on each side of the `>> LABEL <<` line in the ASCII box. */
24
- const CTA_BOX_PAD = 3;
25
-
26
25
  /**
27
26
  * Outlook's answer to a full-box click target.
28
27
  *
@@ -49,14 +48,6 @@ const CTA_BOX_PAD = 3;
49
48
  export const MSO_CTA_STYLE = `.cs-cta td { padding: ${CTA_PAD} !important; }
50
49
  .cs-cta a { padding: 0 !important; }`;
51
50
 
52
- /** `>> LABEL <<` ASCII box (`*` corners): 3 lines, sized to the label. */
53
- function asciiCtaBox(label: string): string {
54
- const pad = " ".repeat(CTA_BOX_PAD);
55
- const inner = `${pad}>> ${label} <<${pad}`;
56
- const border = `*${"-".repeat(inner.length)}*`;
57
- return [border, `|${inner}|`, border].join("\n");
58
- }
59
-
60
51
  /**
61
52
  * The bordered `>> LABEL <<` button (HTML table + ascii text), with `margin` on
62
53
  * the table. A standalone element wraps it as its own line; a chat unit embeds
@@ -107,7 +98,11 @@ export function ctaButton(
107
98
  </td></tr>
108
99
  </table>`;
109
100
 
110
- const text = href ? `${asciiCtaBox(label)}\n\n${href}` : asciiCtaBox(label);
101
+ // The ascii form box, and the destination beneath it when there is one — is
102
+ // drawn by `../ascii` so Slack draws the same box. This surface has no way to
103
+ // carry a link inline, so it flattens the href run to its bare URL, which is
104
+ // exactly what a `text/plain` body can say.
105
+ const text = flattenLines(renderCtaAscii(cta, EMAIL_GEOMETRY));
111
106
 
112
107
  return { html, text };
113
108
  }
@@ -45,6 +45,9 @@ import type {
45
45
  } from "../../content";
46
46
  import type { RenderContext } from "../../context";
47
47
 
48
+ import { flattenLines, signatureLines, wrapText } from "../ascii";
49
+ import { MONOSPACE_COLUMNS } from "../layout";
50
+
48
51
  import { COMPANY_URL, type Spacing } from "./constants";
49
52
  import { renderChatUnit } from "./chat";
50
53
  import { ctaButton } from "./cta";
@@ -52,6 +55,25 @@ import { escapeHtml } from "./escape-html";
52
55
  import type { EmailLine } from "./shells";
53
56
  import { styleClass } from "./styles";
54
57
 
58
+ /**
59
+ * Wrap prose to the column budget — on the PLAIN-TEXT surface only.
60
+ *
61
+ * The two surfaces need opposite things from the same sentence. HTML reflows to
62
+ * whatever width the client gives it, so hard-wrapping the markup would fight
63
+ * the client and leave ragged breaks at every other width; plain text has no
64
+ * reflow at all, so an unwrapped sentence is one long line a phone can only show
65
+ * by scrolling sideways. So this touches `text` and never `html`, and one `<p>`
66
+ * stays one `<p>` however many physical lines its text becomes.
67
+ *
68
+ * Prose only. Key/value rows, the signature's URL and a CTA's label are atomic
69
+ * (`../layout.ts`) and are never routed through here — breaking a device string
70
+ * or a one-time code across lines would corrupt the thing the notification
71
+ * exists to deliver.
72
+ */
73
+ function wrapProse(line: string): string {
74
+ return wrapText(line, MONOSPACE_COLUMNS).join("\n");
75
+ }
76
+
55
77
  /** One `<p>` — the primitive every prose-ish element is built from. */
56
78
  function paragraph(html: string, text: string, spacing: Spacing): EmailLine {
57
79
  return {
@@ -95,7 +117,9 @@ function signatureLine(signer: string, year: number): EmailLine {
95
117
  const line = `ⓒ ${year} • ${signer}`;
96
118
  return {
97
119
  html: `<p ${styleClass("p-none")}><br><br><span ${styleClass("eom", "faint")}>/* EOM */</span><br>${escapeHtml(line)}<br><a href="${escapeHtml(COMPANY_URL)}" target="_blank" rel="noopener noreferrer" ${styleClass("signature-link", "link")}>${escapeHtml(COMPANY_URL)}</a></p>`,
98
- text: `\n\n/* EOM */\n${line}\n${COMPANY_URL}`,
120
+ // The text form is drawn by `../ascii` so Slack signs identically; the two
121
+ // leading blank lines are this surface's own spacing and stay here.
122
+ text: `\n\n${flattenLines(signatureLines(signer, year, COMPANY_URL))}`,
99
123
  spacing: "none",
100
124
  };
101
125
  }
@@ -116,7 +140,7 @@ export function renderElement(
116
140
  const text = element.recipientName
117
141
  ? `Hi ${element.recipientName},`
118
142
  : "Hi there,";
119
- return [paragraph(escapeHtml(text), text, trailing)];
143
+ return [paragraph(escapeHtml(text), wrapProse(text), trailing)];
120
144
  }
121
145
 
122
146
  case "body":
@@ -125,7 +149,7 @@ export function renderElement(
125
149
  return spaced(
126
150
  element.text.split("\n").map((line) => ({
127
151
  html: escapeHtml(line),
128
- text: line,
152
+ text: wrapProse(line),
129
153
  })),
130
154
  trailing,
131
155
  );
@@ -168,7 +192,7 @@ export function renderElement(
168
192
  return [
169
193
  paragraph(
170
194
  element.lines.map(escapeHtml).join("<br>"),
171
- element.lines.join("\n"),
195
+ element.lines.map(wrapProse).join("\n"),
172
196
  trailing,
173
197
  ),
174
198
  ];
@@ -201,7 +225,7 @@ export function renderElement(
201
225
  return spaced(
202
226
  element.items.map((item, i) => {
203
227
  const text = `${element.ordered ? `${i + 1}.` : "•"} ${item}`;
204
- return { html: escapeHtml(text), text };
228
+ return { html: escapeHtml(text), text: wrapProse(text) };
205
229
  }),
206
230
  trailing,
207
231
  );
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The column budget every monospace surface draws inside.
3
+ *
4
+ * One number, shared by the channels that draw in a fixed-width face: email's
5
+ * `text/plain` body and Slack's `rich_text_preformatted` blocks. It lives above
6
+ * both because a budget only one channel honours is not a budget.
7
+ *
8
+ * **Why it is this narrow.** The surfaces are read on phones. A monospace block
9
+ * that overflows its container does not reflow — it scrolls sideways, or gets
10
+ * cut. So the budget is set by the narrowest surface we care about rather than
11
+ * by what a desktop mail client could show, and email accepts the stricter
12
+ * number even though it could afford more. Identical geometry across channels
13
+ * is worth more than the extra columns.
14
+ *
15
+ * **What it binds.** Lines the renderers GENERATE — box borders, bubble frames,
16
+ * rules, padding, and prose they wrap themselves.
17
+ *
18
+ * **What it does not bind.** Atomic content: URLs, one-time codes, identifiers,
19
+ * filenames, tokens, and key/value cell values. These may exceed the budget and
20
+ * must NEVER be broken to satisfy it. A newline inserted into an OTP or a URL is
21
+ * a correctness bug wearing a layout costume, and no layout invariant is worth
22
+ * one. `renderers/ascii/README.md` states the rule; the layout tests enforce the
23
+ * half of it that can be enforced.
24
+ *
25
+ * **What a column is.** A JavaScript string character — see
26
+ * `./ascii/geometry.ts`, which declines to claim Unicode terminal-cell precision
27
+ * and says why.
28
+ */
29
+
30
+ /** Characters of generated monospace layout per line. */
31
+ export const MONOSPACE_COLUMNS = 48;