@company-semantics/contracts 30.0.0 → 32.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.
@@ -58,41 +58,45 @@ add("org.invite", "Admin", {
58
58
  orgName: "Acme Corp",
59
59
  role: "admin",
60
60
  acceptUrl: `${APP}/invite/abc123`,
61
- expiresInDays: 7,
61
+ expiresAt: "2026-06-13T00:00:00.000Z",
62
62
  });
63
63
  add("org.invite", "Member", {
64
64
  inviterName: "Alex Rivera",
65
65
  orgName: "Acme Corp",
66
66
  role: "member",
67
67
  acceptUrl: `${APP}/invite/def456`,
68
- expiresInDays: 7,
68
+ expiresAt: "2026-06-13T00:00:00.000Z",
69
69
  });
70
70
 
71
71
  add("org.unit_owner_granted", "Unit owner + message", {
72
72
  granterName: "Jordan Lee",
73
73
  recipientName: "Sam Chen",
74
+ orgName: "Acme Corp",
74
75
  unitName: "Platform Engineering",
75
76
  roleLabel: "Unit owner",
76
77
  ctaUrl: `${APP}/org/unit/42`,
77
78
  message: "Welcome aboard — glad to have you owning this team.",
78
79
  });
79
- add("org.unit_owner_granted", "Delegate, no message", {
80
+ add("org.unit_owner_granted", "Delegate with expiry", {
80
81
  granterName: "Jordan Lee",
82
+ orgName: "Acme Corp",
81
83
  unitName: "Platform Engineering",
82
84
  roleLabel: "Delegate",
83
85
  ctaUrl: `${APP}/org/unit/42`,
86
+ expiresAt: "2026-06-13T00:00:00.000Z",
84
87
  });
85
88
 
86
- add("org.ownership_transfer", "With note", {
89
+ add("org.ownership_transfer", "With note + from", {
87
90
  orgName: "Acme Corp",
88
91
  acceptUrl: `${APP}/owner-transfer/accept/tok123`,
89
- expiresInDays: 14,
92
+ expiresAt: "2026-06-13T00:00:00.000Z",
90
93
  note: "Handing this over as I move to an advisory role.",
94
+ fromName: "Jordan Lee",
91
95
  });
92
- add("org.ownership_transfer", "No note", {
96
+ add("org.ownership_transfer", "No note (admin)", {
93
97
  orgName: "Acme Corp",
94
98
  acceptUrl: `${APP}/owner-transfer/accept/tok456`,
95
- expiresInDays: 14,
99
+ expiresAt: "2026-06-13T00:00:00.000Z",
96
100
  });
97
101
 
98
102
  add("org.ownership_transfer_completed", "Default", {
@@ -10,8 +10,11 @@ import type { EmailPayloads } from "../types";
10
10
 
11
11
  import {
12
12
  type Block,
13
- ctaBox,
13
+ chatAssistant,
14
+ chatCta,
15
+ chatUnit,
14
16
  footer,
17
+ greeting,
15
18
  keyValue,
16
19
  paragraph,
17
20
  signature,
@@ -32,9 +35,13 @@ export function renderAuthOtp(
32
35
  const { otp, expiresInMinutes, requestIp, userAgent } = payload;
33
36
 
34
37
  const blocks: Block[] = [
35
- paragraph("Copy/paste code in login form:"),
36
- ctaBox({ label: otp, variant: "code" }),
37
- keyValue("Status", "VALID"),
38
+ greeting(),
39
+ paragraph("A login code was requested."),
40
+ chatUnit(
41
+ chatAssistant("Copy + paste this code in the login form."),
42
+ chatCta({ label: otp }),
43
+ ),
44
+ keyValue("Status", "Valid"),
38
45
  keyValue(
39
46
  "Expires in",
40
47
  `${expiresInMinutes} ${expiresInMinutes === 1 ? "minute" : "minutes"}`,
@@ -55,10 +62,10 @@ export function renderAuthOtp(
55
62
 
56
63
  blocks.push(
57
64
  footer(
58
- `This code was generated to authorize a login to ${COMPANY_NAME}.`,
59
- "It expires automatically and cannot be reused.",
65
+ `This login code was sent via ${COMPANY_NAME}.`,
66
+ "If this wasn't you, no action is required.",
67
+ "none",
60
68
  ),
61
- paragraph("If this wasn't you, no action is required."),
62
69
  signature(),
63
70
  );
64
71
 
@@ -8,7 +8,8 @@
8
8
  * text, across the backend (real sends) and the app (Ladle preview).
9
9
  *
10
10
  * INVARIANTS:
11
- * - Pure functions, no side effects.
11
+ * - Pure functions, no side effects — except `signature()`, which reads the
12
+ * current year (`new Date().getFullYear()`) for the copyright line.
12
13
  * - Components escape their own content; templates pass raw text (+ `bold(...)`
13
14
  * for inline emphasis). No template hand-writes markup or raw strings.
14
15
  */
@@ -43,9 +44,17 @@ export interface Inline {
43
44
  /** Paragraph content: raw string(s) (auto-escaped for HTML) and/or `bold(...)`. */
44
45
  export type InlineContent = string | Inline | Array<string | Inline>;
45
46
 
46
- /** Bold inline emphasis (plain in text). */
47
+ /** Inline emphasis segment. Emphasis is currently visually flat — it renders
48
+ * plain (no bold) in both surfaces; the seam is kept so all inline emphasis can
49
+ * be restyled in one place. */
47
50
  export function bold(s: string): Inline {
48
- return { html: `<b>${escapeHtml(s)}</b>`, text: s };
51
+ return { html: escapeHtml(s), text: s };
52
+ }
53
+
54
+ /** Title-case a display value — capitalize the first letter of each word, e.g. a
55
+ * role like "admin" → "Admin". Leaves already-capitalized letters untouched. */
56
+ export function titleCase(s: string): string {
57
+ return s.replace(/\b\w/g, (c) => c.toUpperCase());
49
58
  }
50
59
 
51
60
  /** Normalize a segment: a raw string is escaped for HTML, passed through for text. */
@@ -83,7 +92,9 @@ export function htmlShell(blocks: Block[]): string {
83
92
  <html lang="en">
84
93
  <head><meta charset="UTF-8"></head>
85
94
  <body style="${MONO} color: #1a1a1a; margin: 0; padding: 0;">
95
+ <div style="max-width: 520px; margin: 0 auto;">
86
96
  ${inner}
97
+ </div>
87
98
  </body>
88
99
  </html>`;
89
100
  }
@@ -120,9 +131,30 @@ export function paragraph(
120
131
  };
121
132
  }
122
133
 
123
- /** "Hi Name," / "Hi," greeting. */
134
+ /** "Hi Name," greeting; falls back to "Hi there," when the name is unknown. */
124
135
  export function greeting(recipientName?: string): Block {
125
- return paragraph(recipientName ? `Hi ${recipientName},` : "Hi,");
136
+ return paragraph(recipientName ? `Hi ${recipientName},` : "Hi there,");
137
+ }
138
+
139
+ /** Security banner — the "WARNING" wordmark, sits atop security emails. */
140
+ export function security(): Block {
141
+ return paragraph("🆆🅰🆁🅽🅸🅽🅶");
142
+ }
143
+
144
+ /**
145
+ * Format an ISO timestamp as "Jun 13, 2026". Pinned to en-US + UTC so the
146
+ * output is locale-/timezone-independent and render snapshots stay
147
+ * deterministic. Returns the raw input unchanged if it is not a parseable date.
148
+ */
149
+ export function formatExpiry(iso: string): string {
150
+ const d = new Date(iso);
151
+ if (Number.isNaN(d.getTime())) return iso;
152
+ return d.toLocaleDateString("en-US", {
153
+ month: "short",
154
+ day: "numeric",
155
+ year: "numeric",
156
+ timeZone: "UTC",
157
+ });
126
158
  }
127
159
 
128
160
  /** "Label: **value**" key/value row (value bold in HTML; tight by default). */
@@ -152,12 +184,16 @@ export function footer(
152
184
  }
153
185
 
154
186
  /**
155
- * Trailing sign-off — the company (or custom `signer`) name.
187
+ * Trailing sign-off — a leading blank line, the `---` rule, then the company
188
+ * (or custom `signer`) name. Owns the blank above the rule so every sign-off is
189
+ * spaced identically; the block before it carries no trailing gap.
156
190
  */
157
191
  export function signature(signer: string = COMPANY_NAME): Block {
192
+ const line = `ⓒ ${new Date().getFullYear()} • ${signer}`;
193
+ const url = "https://companysemantics.ai";
158
194
  return {
159
- html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: ${SPACING.none};">${escapeHtml(signer)}</p>`,
160
- text: `---\n${signer}`,
195
+ html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: ${SPACING.none};"><br><br><span style="color: #bbb;">/* EOM */</span><br>${escapeHtml(line)}<br><a href="${url}" target="_blank" rel="noopener noreferrer" style="color: #0047FF; text-decoration: none;">${escapeHtml(url)}</a></p>`,
196
+ text: `\n\n/* EOM */\n${line}\n${url}`,
161
197
  spacing: "none",
162
198
  };
163
199
  }
@@ -173,56 +209,19 @@ export const ACCESS_PHRASE: Record<"editor" | "commenter" | "viewer", string> =
173
209
  viewer: "can view",
174
210
  };
175
211
 
176
- /** Centered continuation dots separator (chat-shared). */
177
- export function chatDots(): Block {
178
- return {
179
- html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: 0 0 16px 0; max-width: 320px; text-align: center;">⋮</p>`,
180
- text: " ⋮",
181
- spacing: "normal",
182
- };
183
- }
184
-
185
212
  // =============================================================================
186
213
  // CTA box
187
214
  // =============================================================================
188
215
 
189
- /** Options for the `>> LABEL <<` call-to-action box. */
216
+ /** Options for the `>> LABEL <<` call-to-action box. Styling is component-owned
217
+ * (the only allowable UI is the component) — callers supply only content. */
190
218
  export interface CtaBoxOptions {
191
219
  /** Text between the chevrons (already display-safe). */
192
220
  label: string;
193
221
  /** When present, the label links to this URL (and the URL rides under the
194
222
  * plain-text box). */
195
223
  href?: string;
196
- /** Visual kind. `"action"` = link CTA (default); `"code"` = copy/paste code
197
- * box (square corners, letter-spaced). */
198
- variant?: "action" | "code";
199
- }
200
-
201
- /** Component-owned styling per CTA variant. Templates supply only `label`/`href`/
202
- * `variant` — the only allowable UI is the component, so all CSS lives here,
203
- * never at the call site. */
204
- interface CtaVariantStyle {
205
- padding: string;
206
- fontSize: string;
207
- maxWidth?: string;
208
- square: boolean;
209
- letterSpacing?: string;
210
- }
211
- const CTA_VARIANTS: Record<"action" | "code", CtaVariantStyle> = {
212
- action: {
213
- padding: "16px 24px",
214
- fontSize: FONT_SIZE,
215
- maxWidth: "220px",
216
- square: false,
217
- },
218
- code: {
219
- padding: "16px 24px",
220
- fontSize: FONT_SIZE,
221
- maxWidth: "220px",
222
- square: true,
223
- letterSpacing: "4px",
224
- },
225
- };
224
+ }
226
225
 
227
226
  /** Padding columns on each side of the `>> LABEL <<` line in the ASCII box. */
228
227
  const CTA_BOX_PAD = 3;
@@ -235,26 +234,24 @@ function asciiCtaBox(label: string): string {
235
234
  return [border, `|${inner}|`, border].join("\n");
236
235
  }
237
236
 
238
- /** The bordered `>> LABEL <<` CTA box (OTP / JOIN / OPEN / VIEW TEAM / …). When
239
- * `href` is given, HTML links the label and plain text prints the URL below. */
240
- export function ctaBox(opts: CtaBoxOptions): Block {
241
- const { label, href, variant = "action" } = opts;
242
- const { padding, fontSize, maxWidth, square, letterSpacing } =
243
- CTA_VARIANTS[variant];
244
-
245
- const tableStyle =
246
- `border: 2px solid #1a1a1a;` +
247
- (square ? ` border-radius: 0;` : ``) +
248
- ` margin: 0 0 20px 0;` +
249
- (maxWidth ? ` max-width: ${maxWidth};` : ``);
250
-
251
- const tdStyle =
252
- `padding: ${padding}; text-align: center; ${MONO} font-size: ${fontSize};` +
253
- (letterSpacing ? ` letter-spacing: ${letterSpacing};` : ``);
254
-
255
- const chevrons = `<b>&gt;&gt; ${escapeHtml(label)} &lt;&lt;</b>`;
237
+ /** The bordered `>> LABEL <<` button (HTML table + ascii text), with `margin` on
238
+ * the table. `ctaBox` wraps it as a standalone block; a chat unit embeds it. */
239
+ function ctaButton(
240
+ opts: CtaBoxOptions,
241
+ margin: string,
242
+ ): { html: string; text: string } {
243
+ const { label, href } = opts;
244
+
245
+ const tableStyle = `display: inline-block; border: 1px solid #666; border-radius: 2px; margin: ${margin}; max-width: 220px;`;
246
+ const tdStyle = `padding: 16px 24px; text-align: center; ${MONO} font-size: ${FONT_SIZE};`;
247
+
248
+ // Underline only the label text (not the chevrons/spaces), and only when linked.
249
+ const labelHtml = href
250
+ ? `<span style="text-decoration: underline;">${escapeHtml(label)}</span>`
251
+ : escapeHtml(label);
252
+ const chevrons = `&gt;&gt; ${labelHtml} &lt;&lt;`;
256
253
  const inner = href
257
- ? `<a href="${href}" style="color: #0047FF; text-decoration: underline;">${chevrons}</a>`
254
+ ? `<a href="${href}" style="color: #0047FF; text-decoration: none;">${chevrons}</a>`
258
255
  : chevrons;
259
256
 
260
257
  const html = `<table cellpadding="0" cellspacing="0" border="0" style="${tableStyle}">
@@ -265,6 +262,13 @@ export function ctaBox(opts: CtaBoxOptions): Block {
265
262
 
266
263
  const text = href ? `${asciiCtaBox(label)}\n\n${href}` : asciiCtaBox(label);
267
264
 
265
+ return { html, text };
266
+ }
267
+
268
+ /** The bordered `>> LABEL <<` CTA box (OTP / JOIN / OPEN / VIEW TEAM / …). When
269
+ * `href` is given, HTML links the label and plain text prints the URL below. */
270
+ export function ctaBox(opts: CtaBoxOptions): Block {
271
+ const { html, text } = ctaButton(opts, "0 0 20px 0");
268
272
  return { html, text, spacing: "normal" };
269
273
  }
270
274
 
@@ -299,7 +303,10 @@ function wrapText(text: string, width: number): string[] {
299
303
 
300
304
  /** Chat message truncation budget: at most MAX_MESSAGE_LINES lines of MESSAGE_WIDTH chars. */
301
305
  const MAX_MESSAGE_LINES = 3;
302
- const MESSAGE_WIDTH = 35;
306
+ const MESSAGE_WIDTH = 36;
307
+ /** Plain-text left gutter (7 cols) reserved for the avatar on both sides, so the
308
+ * user box aligns with the assistant box. */
309
+ const CHAT_INDENT = " ";
303
310
 
304
311
  /** Word-wrap `text`, then clamp to `maxLines`, ellipsizing the last line on overflow. */
305
312
  function wrapClamped(text: string, width: number, maxLines: number): string[] {
@@ -322,59 +329,239 @@ function clampMessage(text: string): string {
322
329
  return wrapClamped(text, MESSAGE_WIDTH, MAX_MESSAGE_LINES).join(" ");
323
330
  }
324
331
 
332
+ /** One message in a chat unit. `chatUser`/`chatAssistant` build these; `chatUnit`
333
+ * lays them out together. `from` is the user attribution (sender name). */
334
+ export interface ChatMessage {
335
+ role: "user" | "assistant";
336
+ text: string;
337
+ from?: string;
338
+ }
339
+
340
+ /** A user (right-aligned) chat message with an optional `from` attribution. */
341
+ export function chatUser(text: string, from?: string): ChatMessage {
342
+ return { role: "user", text, from };
343
+ }
344
+
345
+ /** An assistant (left-aligned) chat message. */
346
+ export function chatAssistant(text: string): ChatMessage {
347
+ return { role: "assistant", text };
348
+ }
349
+
350
+ /** A CTA button placed inside a chat unit (below a message). */
351
+ export interface ChatCta {
352
+ role: "cta";
353
+ cta: CtaBoxOptions;
354
+ }
355
+
356
+ /** Centered continuation dots inside a chat unit — a "conversation continues"
357
+ * separator placed between a message bubble and a following CTA. */
358
+ export interface ChatDots {
359
+ role: "dots";
360
+ }
361
+
362
+ /** An item in a chat unit: a message bubble, a CTA button, or continuation dots. */
363
+ export type ChatItem = ChatMessage | ChatCta | ChatDots;
364
+
365
+ /** A CTA button for a chat unit — pass it to `chatUnit` alongside messages. */
366
+ export function chatCta(cta: CtaBoxOptions): ChatCta {
367
+ return { role: "cta", cta };
368
+ }
369
+
370
+ /** Continuation dots for a chat unit — pass it to `chatUnit` where the
371
+ * conversation should read as continuing (e.g. between the preview and CTA). */
372
+ export function chatDots(): ChatDots {
373
+ return { role: "dots" };
374
+ }
375
+
376
+ /** The `<hr>` bracketing a chat unit — 24px toward the bubbles, 12px on the
377
+ * outer side. */
378
+ function chatRuleHtml(position: "top" | "bottom"): string {
379
+ const margin = position === "top" ? "12px 0 24px 0" : "24px 0 12px 0";
380
+ return `<hr style="border: none; border-top: 1px solid #bbb; margin: ${margin};">`;
381
+ }
382
+
325
383
  /**
326
- * Right-aligned user chat message. Truncated by clampMessage. In plain text the
327
- * face sits beside the last message line. When `from` is given (the sender's
328
- * name) it renders as an attribution below, right-aligned to the message's right
329
- * edge.
384
+ * Render one message to its HTML `<table>` and plain-text box lines. Both roles
385
+ * share the 3-column skeleton: a fixed avatar column on each side (the visible
386
+ * avatar plus the opposite avatar rendered `visibility: hidden` to reserve its
387
+ * width, so bubbles stay bounded and aligned), a middle cell that right/left-
388
+ * aligns the bubble, and — for a user `from` — an attribution row below.
330
389
  */
331
- export function chatUser(content: string, from?: string): Block {
332
- const clamped = clampMessage(content);
333
-
334
- const attributionRow = from
335
- ? `
390
+ function renderBubble(
391
+ msg: ChatMessage,
392
+ margin: string,
393
+ ): { html: string; text: string[] } {
394
+ const clamped = clampMessage(msg.text);
395
+ const isUser = msg.role === "user";
396
+
397
+ const radius = isUser ? "8px 8px 0 8px" : "8px 8px 8px 0";
398
+ const bubbleAlign = isUser ? " text-align: right;" : "";
399
+ const cellAlign = isUser ? "right" : "left";
400
+ const csHidden = isUser ? "visibility: hidden; " : "";
401
+ const kaomojiHidden = isUser ? "" : "visibility: hidden; ";
402
+
403
+ const attributionRow =
404
+ isUser && msg.from
405
+ ? `
336
406
  <tr>
337
- <td style="${MONO} font-size: ${FONT_SIZE}; color: #888; text-align: right; padding-top: 4px;">${escapeHtml(from)}</td>
407
+ <td></td>
408
+ <td style="${MONO} font-size: ${FONT_SIZE}; color: #666; text-align: right; padding-top: 6px; padding-right: 1ch;">${escapeHtml(msg.from)}</td>
338
409
  <td></td>
339
410
  </tr>`
340
- : "";
341
- const html = `<table cellpadding="0" cellspacing="0" border="0" style="margin: 0 0 16px 0;">
411
+ : "";
412
+
413
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: ${margin};">
342
414
  <tr>
343
- <td style="border: 1px solid #1a1a1a; border-radius: 12px 12px 0 12px; padding: 10px 14px; min-width: 280px; text-align: right; ${MONO} font-size: ${FONT_SIZE}; background: #f5f5f5;">${escapeHtml(clamped)}</td>
344
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom;">(•̀_ರ╮)</td>
415
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; ${csHidden}white-space: nowrap;">[c_S]</td>
416
+ <td style="width: 100%; text-align: ${cellAlign};"><table cellpadding="0" cellspacing="0" border="0" style="display: inline-block; max-width: 100%; vertical-align: bottom;">
417
+ <tr><td style="border-radius: ${radius}; padding: 10px 14px;${bubbleAlign} ${MONO} font-size: ${FONT_SIZE}; color: #ffffff; background: #666;">${escapeHtml(clamped)}</td></tr>
418
+ </table></td>
419
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; ${kaomojiHidden}white-space: nowrap;">(•̀_ರ╮)</td>
345
420
  </tr>${attributionRow}
346
421
  </table>`;
347
422
 
348
- const border = "─".repeat(MESSAGE_WIDTH + 1);
423
+ const border = "─".repeat(MESSAGE_WIDTH + 2);
349
424
  const lines = wrapText(clamped, MESSAGE_WIDTH);
350
- const body = lines.map((line) => `│${line.padStart(MESSAGE_WIDTH)} │`);
351
- // Face beside the last message line (one row up from the bottom border).
352
- body[body.length - 1] += " (•̀_ರ╮)";
353
- const box = [`┌${border}┐`, ...body, `└${border}┘`];
354
- if (from) box.push(from.padStart(MESSAGE_WIDTH + 3));
425
+ const body = lines.map(
426
+ (line) =>
427
+ `${CHAT_INDENT}│ ${isUser ? line.padStart(MESSAGE_WIDTH) : line.padEnd(MESSAGE_WIDTH)} │`,
428
+ );
429
+ // Avatar beside the last message line (one row up from the bottom border).
430
+ const last = body.length - 1;
431
+ if (isUser) body[last] += " (•̀_ರ╮)";
432
+ else body[last] = `[c_S] ${body[last].slice(CHAT_INDENT.length)}`;
433
+
434
+ const box = [
435
+ `${CHAT_INDENT}┌${border}┐`,
436
+ ...body,
437
+ `${CHAT_INDENT}└${border}┘`,
438
+ ];
439
+ if (isUser && msg.from) {
440
+ box.push(msg.from.padStart(CHAT_INDENT.length + MESSAGE_WIDTH + 3));
441
+ }
442
+ return { html, text: box };
443
+ }
444
+
445
+ /** The right-edge column the plain-text CTA/dots align to under a user bubble. */
446
+ const CHAT_RIGHT_EDGE = CHAT_INDENT.length + MESSAGE_WIDTH + 4;
355
447
 
356
- return { html, text: box.join("\n"), spacing: "normal" };
448
+ /** Centered "⋮" HTML, sized to sit above and centered over a CTA box (they share
449
+ * the same inline-block, so the dots span exactly the button's width). */
450
+ function dotsOverCtaHtml(): string {
451
+ return `<div style="${MONO} font-size: 20px; font-weight: bold; color: #666; text-align: center; margin: 0 0 16px 0;">⋮</div>`;
357
452
  }
358
453
 
359
- /** Left-aligned assistant chat message with `[c_S]` avatar; in plain text the
360
- * avatar sits beside the last message line (one row up from the bottom border). */
361
- export function chatAssistant(content: string): Block {
362
- const clamped = clampMessage(content);
454
+ /**
455
+ * A CTA button inside the chat stream aligned to the side of the message it
456
+ * follows (`right` under a user bubble, `left` otherwise). Hidden avatar mirrors
457
+ * reserve both columns, so it sits in the message channel and never enters the
458
+ * kaomoji column. The button carries no margin; the row's 24px matches the bubbles.
459
+ * When `withDots`, continuation "⋮" render just above the button, centered over it.
460
+ */
461
+ function renderChatCta(
462
+ cta: CtaBoxOptions,
463
+ align: "left" | "right",
464
+ withDots: boolean,
465
+ ): { html: string; text: string[] } {
466
+ const { html: btnHtml, text: btnText } = ctaButton(cta, "0");
467
+ // Dots + button share one inline-block so the dots center over the button's
468
+ // exact width regardless of label length.
469
+ const stack = `<div style="display: inline-block; text-align: left;">${withDots ? dotsOverCtaHtml() : ""}${btnHtml}</div>`;
470
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 24px 0;">
471
+ <tr>
472
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
473
+ <td style="width: 100%; text-align: ${align};">${stack}</td>
474
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
475
+ </tr>
476
+ </table>`;
363
477
 
364
- const html = `<table cellpadding="0" cellspacing="0" border="0" style="margin: 16px 0;">
478
+ // Right-align to the message box's right edge (under a user bubble); else the
479
+ // left avatar gutter.
480
+ const btnLines = btnText
481
+ .split("\n")
482
+ .map((l) =>
483
+ !l
484
+ ? l
485
+ : align === "right"
486
+ ? l.padStart(CHAT_RIGHT_EDGE)
487
+ : `${CHAT_INDENT}${l}`,
488
+ );
489
+ if (!withDots) return { html, text: btnLines };
490
+
491
+ // Center "⋮" over the ascii box (its first line spans the full box width).
492
+ const boxWidth = btnText.split("\n")[0].length;
493
+ const dotsCol =
494
+ align === "right"
495
+ ? CHAT_RIGHT_EDGE - Math.floor(boxWidth / 2)
496
+ : CHAT_INDENT.length + Math.ceil(boxWidth / 2);
497
+ return { html, text: ["⋮".padStart(dotsCol), "", ...btnLines] };
498
+ }
499
+
500
+ /**
501
+ * Standalone continuation dots — centered in the message channel. Used only when
502
+ * `chatDots()` is NOT immediately followed by a CTA (the common case folds the
503
+ * dots into the CTA via `renderChatCta`, centered over the box).
504
+ */
505
+ function renderChatDots(): { html: string; text: string[] } {
506
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 16px 0;">
365
507
  <tr>
366
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom;">[c_S]</td>
367
- <td style="border: 1px solid #1a1a1a; border-radius: 12px 12px 12px 0; padding: 10px 14px; min-width: 280px; ${MONO} font-size: ${FONT_SIZE}; background: #f5f5f5;">${escapeHtml(clamped)}</td>
508
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
509
+ <td style="width: 100%; text-align: center; ${MONO} font-size: 20px; font-weight: bold; color: #666;">⋮</td>
510
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
368
511
  </tr>
369
512
  </table>`;
513
+ // Center the "⋮" over the message box (avatar gutter + box width + borders).
514
+ const center = Math.round(CHAT_RIGHT_EDGE / 2);
515
+ return { html, text: ["⋮".padStart(center)] };
516
+ }
370
517
 
371
- const lineWidth = 36;
372
- const lines = wrapText(clamped, lineWidth);
373
- const top = " ┌──────────────────────────────────────┐";
374
- const bot = " └──────────────────────────────────────┘";
375
- const boxLines = lines.map((line) => ` │ ${line.padEnd(lineWidth)} │`);
376
- // Avatar beside the last message line (one row up from the bottom border).
377
- boxLines[boxLines.length - 1] = `[c_S] ${boxLines[boxLines.length - 1].slice(7)}`;
518
+ /**
519
+ * Lay out one or more chat items — message bubbles and/or CTA buttons — as a
520
+ * single block: a rule above and below (the real-chat "unit"), the items
521
+ * between. The plain-text rule spans the widest line; a blank line follows the
522
+ * top rule and the last item hugs the bottom rule. Continuation dots
523
+ * (`chatDots`) sit above the CTA they precede, centered over the box.
524
+ */
525
+ export function chatUnit(...items: ChatItem[]): Block {
526
+ const parts: { html: string; text: string[] }[] = [];
527
+ items.forEach((item, i) => {
528
+ if (item.role === "dots") {
529
+ // Dots immediately before a CTA render with it (centered over the box);
530
+ // otherwise fall back to channel-centered standalone dots.
531
+ if (items[i + 1]?.role !== "cta") parts.push(renderChatDots());
532
+ return;
533
+ }
534
+ if (item.role === "cta") {
535
+ // Mirror the side of the nearest preceding message (skip any dots between),
536
+ // so the CTA sits under the bubble it belongs to — right under a user.
537
+ let j = i - 1;
538
+ while (j >= 0 && items[j].role === "dots") j--;
539
+ const align = items[j]?.role === "user" ? "right" : "left";
540
+ parts.push(renderChatCta(item.cta, align, items[i - 1]?.role === "dots"));
541
+ return;
542
+ }
543
+ // A bubble directly above a CTA or continuation dots gets a tighter 16px
544
+ // gap; else 24px.
545
+ const next = items[i + 1]?.role;
546
+ const margin =
547
+ next === "cta" || next === "dots" ? "0 0 16px 0" : "0 0 24px 0";
548
+ parts.push(renderBubble(item, margin));
549
+ });
550
+ const width = parts
551
+ .flatMap((b) => b.text)
552
+ .reduce((w, l) => Math.max(w, l.length), 0);
553
+ const rule = "_".repeat(width);
554
+
555
+ const html = [
556
+ chatRuleHtml("top"),
557
+ ...parts.map((b) => b.html),
558
+ chatRuleHtml("bottom"),
559
+ ].join("\n");
560
+
561
+ const text =
562
+ `${rule}\n\n` +
563
+ parts.map((b) => b.text.join("\n")).join("\n\n") +
564
+ `\n${rule}`;
378
565
 
379
- return { html, text: [top, ...boxLines, bot].join("\n"), spacing: "normal" };
566
+ return { html, text, spacing: "normal" };
380
567
  }
@@ -7,10 +7,12 @@ import type { EmailPayloads } from "../types";
7
7
  import {
8
8
  type Block,
9
9
  chatAssistant,
10
+ chatCta,
10
11
  chatDots,
12
+ chatUnit,
11
13
  chatUser,
12
- ctaBox,
13
14
  footer,
15
+ greeting,
14
16
  NOTICE,
15
17
  paragraph,
16
18
  signature,
@@ -20,22 +22,18 @@ import { COMPANY_NAME } from "./constants";
20
22
  export type ChatSharedPayload = EmailPayloads["chat.shared"];
21
23
 
22
24
  export function renderChatShared(payload: ChatSharedPayload): Block[] {
23
- const { chatTitle, shareUrl, previewText } = payload;
24
-
25
- const blocks: Block[] = [
26
- paragraph("A chat has been shared with you."),
27
- chatUser(chatTitle),
28
- ];
29
-
30
- if (previewText) blocks.push(chatAssistant(previewText));
31
-
32
- blocks.push(
33
- chatDots(),
34
- ctaBox({ label: "SEE MORE", href: shareUrl }),
35
- footer(`This share was sent via ${COMPANY_NAME}.`),
36
- paragraph(NOTICE, "tight"),
25
+ const { chatTitle, shareUrl, previewText, sharedByName } = payload;
26
+
27
+ return [
28
+ greeting(),
29
+ paragraph("A chat was shared with you."),
30
+ chatUnit(
31
+ chatUser(chatTitle, sharedByName),
32
+ ...(previewText ? [chatAssistant(previewText)] : []),
33
+ chatDots(),
34
+ chatCta({ label: "SEE MORE", href: shareUrl }),
35
+ ),
36
+ footer(`This share was sent via ${COMPANY_NAME}.`, NOTICE, "none"),
37
37
  signature(),
38
- );
39
-
40
- return blocks;
38
+ ];
41
39
  }