@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.
- package/package.json +1 -1
- package/src/identity/__tests__/people-org-chart.test.ts +75 -0
- package/src/identity/people-org-chart.ts +25 -6
- package/src/notifications/__tests__/__snapshots__/monospace-budget.test.ts.snap +23 -0
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +309 -259
- package/src/notifications/__tests__/monospace-budget.test.ts +75 -0
- package/src/notifications/renderers/README.md +8 -4
- package/src/notifications/renderers/ascii/README.md +75 -0
- package/src/notifications/renderers/ascii/__tests__/README.md +39 -0
- package/src/notifications/renderers/ascii/__tests__/layout.test.ts +228 -0
- package/src/notifications/renderers/ascii/chat.ts +179 -0
- package/src/notifications/renderers/ascii/cta.ts +57 -0
- package/src/notifications/renderers/ascii/geometry.ts +112 -0
- package/src/notifications/renderers/ascii/index.ts +40 -0
- package/src/notifications/renderers/ascii/keyvalue.ts +34 -0
- package/src/notifications/renderers/ascii/rule.ts +53 -0
- package/src/notifications/renderers/ascii/runs.ts +84 -0
- package/src/notifications/renderers/ascii/signature.ts +41 -0
- package/src/notifications/renderers/ascii/wrap.ts +96 -0
- package/src/notifications/renderers/brand.ts +12 -0
- package/src/notifications/renderers/email/chat.ts +62 -146
- package/src/notifications/renderers/email/constants.ts +17 -2
- package/src/notifications/renderers/email/cta.ts +8 -13
- package/src/notifications/renderers/email/render.ts +29 -5
- package/src/notifications/renderers/layout.ts +31 -0
- package/src/notifications/renderers/slack/README.md +135 -79
- package/src/notifications/renderers/slack/__tests__/README.md +3 -2
- package/src/notifications/renderers/slack/__tests__/index.test.ts +233 -93
- package/src/notifications/renderers/slack/blocks.ts +149 -0
- package/src/notifications/renderers/slack/chat.ts +167 -0
- package/src/notifications/renderers/slack/cta.ts +69 -0
- package/src/notifications/renderers/slack/index.ts +136 -229
- 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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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`
|
|
15
|
-
* truncation authority, and HTML and plain text both run content
|
|
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
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
):
|
|
178
|
-
const { html: btnHtml
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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():
|
|
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
|
-
|
|
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
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
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
|
-
/**
|
|
25
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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;
|