@company-semantics/contracts 27.14.0 → 28.0.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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Auth OTP email (login code). Plain text + HTML.
2
+ * Auth OTP email (login code).
3
3
  *
4
4
  * INVARIANT: the OTP value is a runtime payload field — never a literal in this
5
5
  * package. Request metadata (IP / user agent) is gated by the caller-supplied
@@ -8,14 +8,12 @@
8
8
 
9
9
  import type { EmailPayloads } from "../types";
10
10
 
11
- import { baseTextLayout } from "./base-text";
12
11
  import {
13
- asciiCodeBox,
14
- paragraph,
12
+ type Block,
15
13
  ctaBox,
16
14
  footer,
17
- htmlShell,
18
15
  keyValue,
16
+ paragraph,
19
17
  signature,
20
18
  } from "./blocks";
21
19
  import { COMPANY_NAME } from "./constants";
@@ -27,45 +25,13 @@ export interface RenderOptions {
27
25
  includeRequestMetadata?: boolean;
28
26
  }
29
27
 
30
- export function renderAuthOtpBody(
28
+ export function renderAuthOtp(
31
29
  payload: AuthOtpPayload,
32
30
  options?: RenderOptions,
33
- ): string {
31
+ ): Block[] {
34
32
  const { otp, expiresInMinutes, requestIp, userAgent } = payload;
35
33
 
36
- const sections: string[] = [];
37
- sections.push("Your login code is below.");
38
- sections.push(asciiCodeBox(otp));
39
- sections.push(
40
- `This code expires in ${expiresInMinutes} ${expiresInMinutes === 1 ? "minute" : "minutes"}.`,
41
- );
42
-
43
- // PRIVACY: IP address is PII under GDPR, so this feature is opt-in.
44
- if (options?.includeRequestMetadata && (requestIp || userAgent)) {
45
- sections.push("");
46
- sections.push("Request details:");
47
- if (requestIp) {
48
- sections.push(` IP address: ${requestIp}`);
49
- }
50
- if (userAgent) {
51
- const truncated =
52
- userAgent.length > 80 ? userAgent.substring(0, 77) + "..." : userAgent;
53
- sections.push(` Device: ${truncated}`);
54
- }
55
- }
56
-
57
- sections.push("");
58
- sections.push(
59
- "If you didn't request this code, you can safely ignore this email.",
60
- );
61
-
62
- return baseTextLayout(sections.join("\n"));
63
- }
64
-
65
- export function renderAuthOtpHtml(payload: AuthOtpPayload): string {
66
- const { otp, expiresInMinutes } = payload;
67
-
68
- return htmlShell([
34
+ const blocks: Block[] = [
69
35
  paragraph("Copy/paste code in login form:"),
70
36
  ctaBox({
71
37
  label: otp,
@@ -74,17 +40,33 @@ export function renderAuthOtpHtml(payload: AuthOtpPayload): string {
74
40
  borderRadiusZero: true,
75
41
  letterSpacing: "4px",
76
42
  }),
77
- keyValue("Status", "VALID", "tight"),
43
+ keyValue("Status", "VALID"),
78
44
  keyValue(
79
45
  "Expires in",
80
- `${expiresInMinutes} ${expiresInMinutes === 1 ? "min" : "mins"}`,
46
+ `${expiresInMinutes} ${expiresInMinutes === 1 ? "minute" : "minutes"}`,
81
47
  "normal",
82
48
  ),
49
+ ];
50
+
51
+ // PRIVACY: IP address is PII under GDPR, so this is opt-in.
52
+ if (options?.includeRequestMetadata && (requestIp || userAgent)) {
53
+ blocks.push(paragraph("Request details:", "tight"));
54
+ if (requestIp) blocks.push(keyValue("IP address", requestIp));
55
+ if (userAgent) {
56
+ const truncated =
57
+ userAgent.length > 80 ? userAgent.slice(0, 77) + "..." : userAgent;
58
+ blocks.push(keyValue("Device", truncated, "normal"));
59
+ }
60
+ }
61
+
62
+ blocks.push(
83
63
  footer(
84
64
  `This code was generated to authorize a login to ${COMPANY_NAME}.`,
85
65
  "It expires automatically and cannot be reused.",
86
66
  ),
87
67
  paragraph("If this wasn't you, no action is required."),
88
68
  signature(),
89
- ]);
69
+ );
70
+
71
+ return blocks;
90
72
  }
@@ -1,30 +1,80 @@
1
1
  /**
2
- * Shared email building blocks.
2
+ * Shared email building blocks — dual-output components.
3
3
  *
4
- * The single source of truth for the styled markup every template composes.
5
- * Principle: **blocks own the styling; templates supply only content text.**
6
- * Editing a block here restyles every emailHTML and plain text — across the
7
- * backend (real sends) and the app (Ladle preview).
4
+ * Every component returns a `Block` (`{ html, text }`), so a template composes
5
+ * ONE list of blocks and both surfaces derive from the same source. Principle:
6
+ * **blocks own the styling; templates supply only content the only allowable
7
+ * UI is the components.** Editing a block restyles every email, HTML and plain
8
+ * text, across the backend (real sends) and the app (Ladle preview).
8
9
  *
9
10
  * INVARIANTS:
10
- * - Pure string functions, no side effects.
11
- * - Callers pass already-escaped content for any user-controlled HTML field.
11
+ * - Pure functions, no side effects.
12
+ * - Components escape their own content; templates pass raw text (+ `bold(...)`
13
+ * for inline emphasis). No template hand-writes markup or raw strings.
12
14
  */
13
15
 
14
- import { COMPANY_NAME, MONO_FONT_STACK } from "./constants";
16
+ import { COMPANY_NAME, MONO_FONT_STACK, SUPPORT_EMAIL } from "./constants";
15
17
  import { escapeHtml } from "./escape-html";
16
18
 
17
19
  const MONO = `font-family: ${MONO_FONT_STACK};`;
18
20
 
19
21
  // =============================================================================
20
- // HTML — shell
22
+ // Core types
23
+ // =============================================================================
24
+
25
+ /** A rendered block — both presentations of one component. `spacing` controls
26
+ * the plain-text gap AFTER this block ("normal" = blank line, else none). */
27
+ export interface Block {
28
+ html: string;
29
+ text: string;
30
+ spacing: Spacing;
31
+ }
32
+
33
+ /** An inline segment — for in-line emphasis inside a paragraph. */
34
+ export interface Inline {
35
+ html: string;
36
+ text: string;
37
+ }
38
+
39
+ /** Paragraph content: raw string(s) (auto-escaped for HTML) and/or `bold(...)`. */
40
+ export type InlineContent = string | Inline | Array<string | Inline>;
41
+
42
+ /** Bold inline emphasis (plain in text). */
43
+ export function bold(s: string): Inline {
44
+ return { html: `<b>${escapeHtml(s)}</b>`, text: s };
45
+ }
46
+
47
+ /** Normalize a segment: a raw string is escaped for HTML, passed through for text. */
48
+ function toInline(seg: string | Inline): Inline {
49
+ return typeof seg === "string" ? { html: escapeHtml(seg), text: seg } : seg;
50
+ }
51
+
52
+ function renderInline(content: InlineContent): { html: string; text: string } {
53
+ const segs = (Array.isArray(content) ? content : [content]).map(toInline);
54
+ return {
55
+ html: segs.map((s) => s.html).join(""),
56
+ text: segs.map((s) => s.text).join(""),
57
+ };
58
+ }
59
+
60
+ /** Vertical spacing options for a paragraph. */
61
+ export type Spacing = "normal" | "tight" | "none";
62
+ const SPACING: Record<Spacing, string> = {
63
+ normal: "0 0 20px 0",
64
+ tight: "0 0 4px 0",
65
+ none: "0",
66
+ };
67
+
68
+ // =============================================================================
69
+ // Shells
21
70
  // =============================================================================
22
71
 
23
72
  /** Wrap body blocks in the shared `<!DOCTYPE>` monospace shell. */
24
- export function htmlShell(blocks: string | string[]): string {
25
- const inner = Array.isArray(blocks)
26
- ? blocks.filter(Boolean).join("\n")
27
- : blocks;
73
+ export function htmlShell(blocks: Block[]): string {
74
+ const inner = blocks
75
+ .map((b) => b.html)
76
+ .filter(Boolean)
77
+ .join("\n");
28
78
  return `<!DOCTYPE html>
29
79
  <html lang="en">
30
80
  <head><meta charset="UTF-8"></head>
@@ -34,56 +84,79 @@ ${inner}
34
84
  </html>`;
35
85
  }
36
86
 
87
+ /** Join body blocks into the plain-text email (blank-line separated per each
88
+ * block's spacing, trailing newline). */
89
+ export function textShell(blocks: Block[]): string {
90
+ let out = "";
91
+ blocks.forEach((b, i) => {
92
+ out += b.text;
93
+ if (i < blocks.length - 1) out += b.spacing === "normal" ? "\n\n" : "\n";
94
+ });
95
+ return out.trim() + "\n";
96
+ }
97
+
37
98
  // =============================================================================
38
- // HTML — paragraphs
99
+ // Paragraphs
39
100
  // =============================================================================
40
101
 
41
- /** Vertical spacing options for a mono paragraph. */
42
- export type Spacing = "normal" | "tight" | "none";
43
- const SPACING: Record<Spacing, string> = {
44
- normal: "0 0 20px 0",
45
- tight: "0 0 4px 0",
46
- none: "0",
47
- };
48
-
49
102
  /**
50
- * Email paragraph — mono (matching the body shell), 14px, with configurable
51
- * vertical spacing. The single paragraph primitive: greeting / meta / footer /
52
- * signature all build on it, and templates use it directly for body lines.
103
+ * Email paragraph — mono (matching the body shell), 14px, configurable spacing.
104
+ * The single paragraph primitive: greeting / keyValue / footer / signature all
105
+ * build on it, and templates use it directly for body lines.
53
106
  */
54
- export function paragraph(html: string, spacing: Spacing = "normal"): string {
55
- return `<p style="${MONO} font-size: 14px; margin: ${SPACING[spacing]};">${html}</p>`;
107
+ export function paragraph(
108
+ content: InlineContent,
109
+ spacing: Spacing = "normal",
110
+ ): Block {
111
+ const { html, text } = renderInline(content);
112
+ return {
113
+ html: `<p style="${MONO} font-size: 14px; margin: ${SPACING[spacing]};">${html}</p>`,
114
+ text,
115
+ spacing,
116
+ };
56
117
  }
57
118
 
58
- /** "Hi Name," / "Hi," greeting. Escapes the name. */
59
- export function greeting(recipientName?: string): string {
60
- return paragraph(recipientName ? `Hi ${escapeHtml(recipientName)},` : "Hi,");
119
+ /** "Hi Name," / "Hi," greeting. */
120
+ export function greeting(recipientName?: string): Block {
121
+ return paragraph(recipientName ? `Hi ${recipientName},` : "Hi,");
61
122
  }
62
123
 
63
- /** "Label: <b>value</b>" key/value row. */
124
+ /** "Label: **value**" key/value row (value bold in HTML; tight by default). */
64
125
  export function keyValue(
65
126
  label: string,
66
- valueHtml: string,
127
+ value: string,
67
128
  spacing: Spacing = "tight",
68
- ): string {
69
- return paragraph(`${label}: <b>${valueHtml}</b>`, spacing);
129
+ ): Block {
130
+ return paragraph([`${label}: `, bold(value)], spacing);
70
131
  }
71
132
 
72
- /** Footer paragraph: first line + optional second line after a `<br>`. */
133
+ /** Footer paragraph: first line + optional second line after a line break. */
73
134
  export function footer(
74
135
  firstLine: string,
75
136
  secondLine?: string,
76
137
  spacing: Spacing = "normal",
77
- ): string {
78
- return paragraph(
79
- secondLine ? `${firstLine}<br>${secondLine}` : firstLine,
138
+ ): Block {
139
+ const html = secondLine
140
+ ? `${escapeHtml(firstLine)}<br>${escapeHtml(secondLine)}`
141
+ : escapeHtml(firstLine);
142
+ const text = secondLine ? `${firstLine}\n${secondLine}` : firstLine;
143
+ return {
144
+ html: `<p style="${MONO} font-size: 14px; margin: ${SPACING[spacing]};">${html}</p>`,
145
+ text,
80
146
  spacing,
81
- );
147
+ };
82
148
  }
83
149
 
84
- /** Trailing company signature paragraph. */
85
- export function signature(): string {
86
- return paragraph(COMPANY_NAME, "none");
150
+ /**
151
+ * Trailing sign-off. Owns the full company sign-off in both surfaces: the
152
+ * company (or custom `signer`) name and a "Questions? Contact <support>" line.
153
+ */
154
+ export function signature(signer: string = COMPANY_NAME): Block {
155
+ return {
156
+ html: `<p style="${MONO} font-size: 14px; margin: ${SPACING.none};">${escapeHtml(signer)}<br><span style="color: #888;">Questions? Contact <a href="mailto:${SUPPORT_EMAIL}" style="color: #888;">${SUPPORT_EMAIL}</a></span></p>`,
157
+ text: `---\n${signer}\nQuestions? Contact ${SUPPORT_EMAIL}`,
158
+ spacing: "none",
159
+ };
87
160
  }
88
161
 
89
162
  /** The standard "no action required" reassurance line (content constant). */
@@ -98,19 +171,24 @@ export const ACCESS_PHRASE: Record<"editor" | "commenter" | "viewer", string> =
98
171
  };
99
172
 
100
173
  /** Centered continuation dots separator (chat-shared). */
101
- export function chatDots(): string {
102
- return `<p style="${MONO} font-size: 20px; margin: 0 0 16px 0; max-width: 320px; text-align: center;">⋮</p>`;
174
+ export function chatDots(): Block {
175
+ return {
176
+ html: `<p style="${MONO} font-size: 20px; margin: 0 0 16px 0; max-width: 320px; text-align: center;">⋮</p>`,
177
+ text: " ⋮",
178
+ spacing: "normal",
179
+ };
103
180
  }
104
181
 
105
182
  // =============================================================================
106
- // HTML — CTA box
183
+ // CTA box
107
184
  // =============================================================================
108
185
 
109
186
  /** Options for the `>> LABEL <<` call-to-action box. */
110
187
  export interface CtaBoxOptions {
111
188
  /** Text between the chevrons (already display-safe). */
112
189
  label: string;
113
- /** When present, the label links to this URL. */
190
+ /** When present, the label links to this URL (and the URL rides under the
191
+ * plain-text box). */
114
192
  href?: string;
115
193
  /** Inner-cell padding, e.g. `"16px 24px"`. */
116
194
  padding: string;
@@ -124,8 +202,20 @@ export interface CtaBoxOptions {
124
202
  letterSpacing?: string;
125
203
  }
126
204
 
127
- /** The bordered `>> LABEL <<` CTA box (OTP / JOIN / OPEN / VIEW TEAM / …). */
128
- export function ctaBox(opts: CtaBoxOptions): string {
205
+ /** Padding columns on each side of the `>> LABEL <<` line in the ASCII box. */
206
+ const CTA_BOX_PAD = 3;
207
+
208
+ /** `>> LABEL <<` ASCII box (`*` corners): 3 lines, sized to the label. */
209
+ function asciiCtaBox(label: string): string {
210
+ const pad = " ".repeat(CTA_BOX_PAD);
211
+ const inner = `${pad}>> ${label} <<${pad}`;
212
+ const border = `*${"-".repeat(inner.length)}*`;
213
+ return [border, `|${inner}|`, border].join("\n");
214
+ }
215
+
216
+ /** The bordered `>> LABEL <<` CTA box (OTP / JOIN / OPEN / VIEW TEAM / …). When
217
+ * `href` is given, HTML links the label and plain text prints the URL below. */
218
+ export function ctaBox(opts: CtaBoxOptions): Block {
129
219
  const {
130
220
  label,
131
221
  href,
@@ -146,98 +236,24 @@ export function ctaBox(opts: CtaBoxOptions): string {
146
236
  `padding: ${padding}; text-align: center; ${MONO} font-size: ${fontSize};` +
147
237
  (letterSpacing ? ` letter-spacing: ${letterSpacing};` : ``);
148
238
 
149
- const chevrons = `<b>&gt;&gt; ${label} &lt;&lt;</b>`;
239
+ const chevrons = `<b>&gt;&gt; ${escapeHtml(label)} &lt;&lt;</b>`;
150
240
  const inner = href
151
241
  ? `<a href="${href}" style="color: #0047FF; text-decoration: underline;">${chevrons}</a>`
152
242
  : chevrons;
153
243
 
154
- return `<table cellpadding="0" cellspacing="0" border="0" style="${tableStyle}">
244
+ const html = `<table cellpadding="0" cellspacing="0" border="0" style="${tableStyle}">
155
245
  <tr><td style="${tdStyle}">
156
246
  ${inner}
157
247
  </td></tr>
158
248
  </table>`;
159
- }
160
-
161
- // =============================================================================
162
- // HTML — chat bubbles
163
- // =============================================================================
164
-
165
- /**
166
- * Right-aligned user chat bubble. Pass raw text — it is truncated by
167
- * clampMessage and escaped here. When `from` is given (raw) it renders as an
168
- * attribution line below the bubble, right-aligned to the bubble's right edge
169
- * (clear of the face). Doubles as the "message from …" quote.
170
- */
171
- export function chatUserBubble(text: string, from?: string): string {
172
- const contentHtml = escapeHtml(clampMessage(text));
173
- const attributionRow = from
174
- ? `
175
- <tr>
176
- <td style="${MONO} font-size: 11px; color: #888; text-align: right; padding-top: 4px;">${escapeHtml(from)}</td>
177
- <td></td>
178
- </tr>`
179
- : "";
180
- return `<table cellpadding="0" cellspacing="0" border="0" style="margin: 0 0 16px 0;">
181
- <tr>
182
- <td style="border: 1px solid #1a1a1a; border-radius: 12px 12px 0 12px; padding: 10px 14px; min-width: 280px; text-align: right; ${MONO} font-size: 13px; background: #f5f5f5;">${contentHtml}</td>
183
- <td style="${MONO} font-size: 14px; padding-left: 8px; vertical-align: bottom;">(•̀_ರ╮)</td>
184
- </tr>${attributionRow}
185
- </table>`;
186
- }
187
-
188
- /** Left-aligned assistant chat bubble. Pass raw text — truncated + escaped here. */
189
- export function chatAssistantBubble(text: string): string {
190
- const contentHtml = escapeHtml(clampMessage(text));
191
- return `<table cellpadding="0" cellspacing="0" border="0" style="margin: 16px 0;">
192
- <tr>
193
- <td style="${MONO} font-size: 14px; padding-right: 8px; vertical-align: bottom;">[c_S]</td>
194
- <td style="border: 1px solid #1a1a1a; border-radius: 12px 12px 12px 0; padding: 10px 14px; min-width: 280px; ${MONO} font-size: 13px; background: #f5f5f5;">${contentHtml}</td>
195
- </tr>
196
- </table>`;
197
- }
198
-
199
- // =============================================================================
200
- // Plain text — CTA / code boxes
201
- // =============================================================================
202
-
203
- const CODE_BOX_WIDTH = 18;
204
249
 
205
- /** Padding columns on each side of the `>> LABEL <<` line in the CTA box. */
206
- const CTA_BOX_PAD = 3;
250
+ const text = href ? `${asciiCtaBox(label)}\n\n${href}` : asciiCtaBox(label);
207
251
 
208
- function centered(value: string, width: number): string {
209
- const pad = Math.max(0, width - value.length);
210
- const left = Math.floor(pad / 2);
211
- const right = pad - left;
212
- return `${" ".repeat(left)}${value}${" ".repeat(right)}`;
213
- }
214
-
215
- /**
216
- * `>> LABEL <<` ASCII box (`*` corners): 3 lines, sized to the label with
217
- * `CTA_BOX_PAD` padding columns on each side.
218
- */
219
- export function asciiCtaBox(label: string): string {
220
- const pad = " ".repeat(CTA_BOX_PAD);
221
- const inner = `${pad}>> ${label} <<${pad}`;
222
- const border = `*${"-".repeat(inner.length)}*`;
223
- return [border, `|${inner}|`, border].join("\n");
224
- }
225
-
226
- /** ASCII box that displays a code value (OTP; `+` corners, centered). */
227
- export function asciiCodeBox(value: string): string {
228
- const border = `+${"-".repeat(CODE_BOX_WIDTH)}+`;
229
- const blank = `|${" ".repeat(CODE_BOX_WIDTH)}|`;
230
- return [
231
- border,
232
- blank,
233
- `|${centered(value, CODE_BOX_WIDTH)}|`,
234
- blank,
235
- border,
236
- ].join("\n");
252
+ return { html, text, spacing: "normal" };
237
253
  }
238
254
 
239
255
  // =============================================================================
240
- // Plain textchat boxes
256
+ // Chat bubblesone dual-output block per role
241
257
  // =============================================================================
242
258
 
243
259
  /** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
@@ -281,46 +297,63 @@ function wrapClamped(text: string, width: number, maxLines: number): string[] {
281
297
  return kept;
282
298
  }
283
299
 
284
- /** Plain-text continuation dots, centered under the 38-char chat box. */
285
- export function chatDotsText(): string {
286
- return " ⋮";
287
- }
288
-
289
300
  /**
290
301
  * The one truncation authority: clamp a raw message to MAX_MESSAGE_LINES ×
291
- * MESSAGE_WIDTH, ellipsized. Every chat box/bubble runs its content through
292
- * this, so HTML and plain text truncate at exactly the same point. Pass raw
293
- * (unescaped) text.
302
+ * MESSAGE_WIDTH, ellipsized. Both surfaces of a chat block run content through
303
+ * this, so HTML and plain text truncate at exactly the same point.
294
304
  */
295
- export function clampMessage(text: string): string {
305
+ function clampMessage(text: string): string {
296
306
  return wrapClamped(text, MESSAGE_WIDTH, MAX_MESSAGE_LINES).join(" ");
297
307
  }
298
308
 
299
309
  /**
300
- * Right-aligned user chat box. `content` is truncated by clampMessage, then
301
- * wrapped for display. The kaomoji rides the bottom border bottom-aligned
302
- * like a chat avatar. When `from` is given it renders as an attribution line
303
- * below the box, right-aligned to the box's right edge (clear of the face).
304
- * Doubles as the "message from …" quote.
310
+ * Right-aligned user chat message. Truncated by clampMessage. The avatar rides
311
+ * the bottom (bottom-aligned like a real chat). When `from` is given it renders
312
+ * as an attribution below, right-aligned to the message's right edge (clear of
313
+ * the face). Doubles as the "message from …" quote.
305
314
  */
306
- export function chatUserBoxText(content: string, from?: string): string {
315
+ export function chatUser(content: string, from?: string): Block {
316
+ const clamped = clampMessage(content);
317
+
318
+ const attributionRow = from
319
+ ? `
320
+ <tr>
321
+ <td style="${MONO} font-size: 11px; color: #888; text-align: right; padding-top: 4px;">${escapeHtml(from)}</td>
322
+ <td></td>
323
+ </tr>`
324
+ : "";
325
+ const html = `<table cellpadding="0" cellspacing="0" border="0" style="margin: 0 0 16px 0;">
326
+ <tr>
327
+ <td style="border: 1px solid #1a1a1a; border-radius: 12px 12px 0 12px; padding: 10px 14px; min-width: 280px; text-align: right; ${MONO} font-size: 13px; background: #f5f5f5;">${escapeHtml(clamped)}</td>
328
+ <td style="${MONO} font-size: 14px; padding-left: 8px; vertical-align: bottom;">(•̀_ರ╮)</td>
329
+ </tr>${attributionRow}
330
+ </table>`;
331
+
307
332
  const border = "─".repeat(MESSAGE_WIDTH + 1);
308
- const lines = wrapText(clampMessage(content), MESSAGE_WIDTH);
333
+ const lines = wrapText(clamped, MESSAGE_WIDTH);
309
334
  const body = lines.map((line) => `│${line.padStart(MESSAGE_WIDTH)} │`);
310
335
  const box = [`┌${border}┐`, ...body, `└${border}┘ (•̀_ರ╮)`];
311
336
  if (from) box.push(from.padStart(MESSAGE_WIDTH + 3));
312
- return box.join("\n");
337
+
338
+ return { html, text: box.join("\n"), spacing: "normal" };
313
339
  }
314
340
 
315
- /** Left-aligned assistant chat box with `[c_S]` label (clampMessage-truncated, 36 wide). */
316
- export function chatAssistantBoxText(text: string): string {
317
- const lineWidth = 36;
318
- const lines = wrapText(clampMessage(text), lineWidth);
341
+ /** Left-aligned assistant chat message with `[c_S]` avatar (bottom-aligned). */
342
+ export function chatAssistant(content: string): Block {
343
+ const clamped = clampMessage(content);
319
344
 
345
+ const html = `<table cellpadding="0" cellspacing="0" border="0" style="margin: 16px 0;">
346
+ <tr>
347
+ <td style="${MONO} font-size: 14px; padding-right: 8px; vertical-align: bottom;">[c_S]</td>
348
+ <td style="border: 1px solid #1a1a1a; border-radius: 12px 12px 12px 0; padding: 10px 14px; min-width: 280px; ${MONO} font-size: 13px; background: #f5f5f5;">${escapeHtml(clamped)}</td>
349
+ </tr>
350
+ </table>`;
351
+
352
+ const lineWidth = 36;
353
+ const lines = wrapText(clamped, lineWidth);
320
354
  const top = " ┌──────────────────────────────────────┐";
321
- // [c_S] rides the bottom border — bottom-aligned like a chat avatar.
322
355
  const bot = "[c_S] └──────────────────────────────────────┘";
323
356
  const boxLines = lines.map((line) => ` │ ${line.padEnd(lineWidth)} │`);
324
357
 
325
- return [top, ...boxLines, bot].join("\n");
358
+ return { html, text: [top, ...boxLines, bot].join("\n"), spacing: "normal" };
326
359
  }
@@ -1,64 +1,35 @@
1
1
  /**
2
- * Chat-shared email (someone shared a chat). Plain text + HTML.
2
+ * Chat-shared email (someone shared a chat).
3
3
  */
4
4
 
5
5
  import type { EmailPayloads } from "../types";
6
6
 
7
- import { baseTextLayout } from "./base-text";
8
7
  import {
9
- asciiCtaBox,
10
- paragraph,
11
- chatAssistantBoxText,
12
- chatAssistantBubble,
8
+ type Block,
9
+ chatAssistant,
13
10
  chatDots,
14
- chatDotsText,
15
- chatUserBoxText,
16
- chatUserBubble,
11
+ chatUser,
17
12
  ctaBox,
18
13
  footer,
19
- htmlShell,
20
14
  NOTICE,
15
+ paragraph,
21
16
  signature,
22
17
  } from "./blocks";
23
18
  import { COMPANY_NAME } from "./constants";
24
19
 
25
20
  export type ChatSharedPayload = EmailPayloads["chat.shared"];
26
21
 
27
- export function renderChatSharedBody(payload: ChatSharedPayload): string {
22
+ export function renderChatShared(payload: ChatSharedPayload): Block[] {
28
23
  const { chatTitle, shareUrl, previewText } = payload;
29
24
 
30
- const sections: string[] = [];
31
- sections.push("A chat has been shared with you.");
32
- sections.push("");
33
- sections.push(chatUserBoxText(chatTitle));
34
-
35
- if (previewText) {
36
- sections.push("");
37
- sections.push(chatAssistantBoxText(previewText));
38
- }
39
-
40
- sections.push("");
41
- sections.push(chatDotsText());
42
- sections.push("");
43
- sections.push(asciiCtaBox("SEE MORE"));
44
- sections.push("");
45
- sections.push(shareUrl);
46
- sections.push("");
47
- sections.push(`This share was sent via ${COMPANY_NAME}.`);
48
- sections.push("");
49
- sections.push(NOTICE);
50
-
51
- return baseTextLayout(sections.join("\n"));
52
- }
53
-
54
- export function renderChatSharedHtml(payload: ChatSharedPayload): string {
55
- const { chatTitle, shareUrl, previewText } = payload;
25
+ const blocks: Block[] = [
26
+ paragraph("A chat has been shared with you."),
27
+ chatUser(chatTitle),
28
+ ];
56
29
 
57
- const previewHtml = previewText ? chatAssistantBubble(previewText) : "";
30
+ if (previewText) blocks.push(chatAssistant(previewText));
58
31
 
59
- return htmlShell([
60
- paragraph("A chat has been shared with you."),
61
- chatUserBubble(chatTitle) + previewHtml,
32
+ blocks.push(
62
33
  chatDots(),
63
34
  ctaBox({
64
35
  label: "SEE MORE",
@@ -70,5 +41,7 @@ export function renderChatSharedHtml(payload: ChatSharedPayload): string {
70
41
  footer(`This share was sent via ${COMPANY_NAME}.`),
71
42
  paragraph(NOTICE, "tight"),
72
43
  signature(),
73
- ]);
44
+ );
45
+
46
+ return blocks;
74
47
  }