@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.
- package/package.json +4 -4
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +5 -11
- package/src/content/schemas.ts +9 -20
- package/src/email/__tests__/registry.test.ts +3 -3
- package/src/email/registry.ts +6 -4
- package/src/email/render/__tests__/__snapshots__/render-snapshot.test.ts.snap +1141 -454
- package/src/email/render/__tests__/render-snapshot.test.ts +11 -7
- package/src/email/render/auth-otp.ts +14 -7
- package/src/email/render/blocks.ts +291 -104
- package/src/email/render/chat-shared.ts +16 -18
- package/src/email/render/company-md-access-approved.ts +17 -10
- package/src/email/render/company-md-access-denied.ts +11 -9
- package/src/email/render/company-md-access-requested.ts +21 -20
- package/src/email/render/index.ts +6 -0
- package/src/email/render/org-invite.ts +16 -7
- package/src/email/render/ownership-transfer-completed.ts +20 -11
- package/src/email/render/ownership-transfer.ts +22 -22
- package/src/email/render/render-email.ts +39 -1
- package/src/email/render/security-alert.ts +11 -12
- package/src/email/render/share-granted.ts +13 -16
- package/src/email/render/unit-owner-granted.ts +38 -24
- package/src/email/types.ts +10 -3
|
@@ -58,41 +58,45 @@ add("org.invite", "Admin", {
|
|
|
58
58
|
orgName: "Acme Corp",
|
|
59
59
|
role: "admin",
|
|
60
60
|
acceptUrl: `${APP}/invite/abc123`,
|
|
61
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
59
|
-
"
|
|
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
|
-
/**
|
|
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:
|
|
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,"
|
|
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
|
|
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(
|
|
160
|
-
text:
|
|
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
|
-
|
|
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 <<`
|
|
239
|
-
* `
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const chevrons = `<b>>> ${escapeHtml(label)} <<</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 = `>> ${labelHtml} <<`;
|
|
256
253
|
const inner = href
|
|
257
|
-
? `<a href="${href}" style="color: #0047FF; text-decoration:
|
|
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 =
|
|
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
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
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
|
-
|
|
411
|
+
: "";
|
|
412
|
+
|
|
413
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: ${margin};">
|
|
342
414
|
<tr>
|
|
343
|
-
<td style="
|
|
344
|
-
<td style="
|
|
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 +
|
|
423
|
+
const border = "─".repeat(MESSAGE_WIDTH + 2);
|
|
349
424
|
const lines = wrapText(clamped, MESSAGE_WIDTH);
|
|
350
|
-
const body = lines.map(
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
360
|
-
*
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
}
|