@company-semantics/contracts 27.13.1 → 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.
- package/package.json +1 -1
- package/src/email/render/__tests__/__snapshots__/render-snapshot.test.ts.snap +281 -221
- package/src/email/render/auth-otp.ts +31 -50
- package/src/email/render/blocks.ts +230 -179
- package/src/email/render/chat-shared.ts +19 -61
- package/src/email/render/company-md-access-approved.ts +16 -40
- package/src/email/render/company-md-access-denied.ts +23 -44
- package/src/email/render/company-md-access-requested.ts +23 -48
- package/src/email/render/constants.ts +0 -6
- package/src/email/render/index.ts +15 -58
- package/src/email/render/org-invite.ts +14 -37
- package/src/email/render/ownership-transfer-completed.ts +19 -17
- package/src/email/render/ownership-transfer.ts +36 -21
- package/src/email/render/render-email.ts +110 -100
- package/src/email/render/security-alert.ts +27 -27
- package/src/email/render/share-granted.ts +27 -58
- package/src/email/render/unit-owner-granted.ts +26 -50
- package/src/email/render/base-text.ts +0 -26
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auth OTP email (login code).
|
|
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,16 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
import type { EmailPayloads } from "../types";
|
|
10
10
|
|
|
11
|
-
import { baseTextLayout } from "./base-text";
|
|
12
11
|
import {
|
|
13
|
-
|
|
14
|
-
bodyParagraph,
|
|
12
|
+
type Block,
|
|
15
13
|
ctaBox,
|
|
16
14
|
footer,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
signatureLine,
|
|
15
|
+
keyValue,
|
|
16
|
+
paragraph,
|
|
17
|
+
signature,
|
|
21
18
|
} from "./blocks";
|
|
22
19
|
import { COMPANY_NAME } from "./constants";
|
|
23
20
|
|
|
@@ -28,46 +25,14 @@ export interface RenderOptions {
|
|
|
28
25
|
includeRequestMetadata?: boolean;
|
|
29
26
|
}
|
|
30
27
|
|
|
31
|
-
export function
|
|
28
|
+
export function renderAuthOtp(
|
|
32
29
|
payload: AuthOtpPayload,
|
|
33
30
|
options?: RenderOptions,
|
|
34
|
-
):
|
|
31
|
+
): Block[] {
|
|
35
32
|
const { otp, expiresInMinutes, requestIp, userAgent } = payload;
|
|
36
33
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
sections.push(asciiCodeBox(otp));
|
|
40
|
-
sections.push(
|
|
41
|
-
`This code expires in ${expiresInMinutes} ${expiresInMinutes === 1 ? "minute" : "minutes"}.`,
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
// PRIVACY: IP address is PII under GDPR, so this feature is opt-in.
|
|
45
|
-
if (options?.includeRequestMetadata && (requestIp || userAgent)) {
|
|
46
|
-
sections.push("");
|
|
47
|
-
sections.push("Request details:");
|
|
48
|
-
if (requestIp) {
|
|
49
|
-
sections.push(` IP address: ${requestIp}`);
|
|
50
|
-
}
|
|
51
|
-
if (userAgent) {
|
|
52
|
-
const truncated =
|
|
53
|
-
userAgent.length > 80 ? userAgent.substring(0, 77) + "..." : userAgent;
|
|
54
|
-
sections.push(` Device: ${truncated}`);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
sections.push("");
|
|
59
|
-
sections.push(
|
|
60
|
-
"If you didn't request this code, you can safely ignore this email.",
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
return baseTextLayout(sections.join("\n"));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function renderAuthOtpHtml(payload: AuthOtpPayload): string {
|
|
67
|
-
const { otp, expiresInMinutes } = payload;
|
|
68
|
-
|
|
69
|
-
return htmlShell([
|
|
70
|
-
bodyParagraph("Copy/paste code in login form:"),
|
|
34
|
+
const blocks: Block[] = [
|
|
35
|
+
paragraph("Copy/paste code in login form:"),
|
|
71
36
|
ctaBox({
|
|
72
37
|
label: otp,
|
|
73
38
|
padding: "20px 24px",
|
|
@@ -75,17 +40,33 @@ export function renderAuthOtpHtml(payload: AuthOtpPayload): string {
|
|
|
75
40
|
borderRadiusZero: true,
|
|
76
41
|
letterSpacing: "4px",
|
|
77
42
|
}),
|
|
78
|
-
|
|
79
|
-
|
|
43
|
+
keyValue("Status", "VALID"),
|
|
44
|
+
keyValue(
|
|
80
45
|
"Expires in",
|
|
81
|
-
`${expiresInMinutes} ${expiresInMinutes === 1 ? "
|
|
46
|
+
`${expiresInMinutes} ${expiresInMinutes === 1 ? "minute" : "minutes"}`,
|
|
82
47
|
"normal",
|
|
83
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(
|
|
84
63
|
footer(
|
|
85
64
|
`This code was generated to authorize a login to ${COMPANY_NAME}.`,
|
|
86
65
|
"It expires automatically and cannot be reused.",
|
|
87
66
|
),
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
67
|
+
paragraph("If this wasn't you, no action is required."),
|
|
68
|
+
signature(),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return blocks;
|
|
91
72
|
}
|
|
@@ -1,48 +1,63 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared email building blocks.
|
|
2
|
+
* Shared email building blocks — dual-output components.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
11
|
-
* -
|
|
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 {
|
|
15
|
-
COMPANY_NAME,
|
|
16
|
-
MONO_FONT_STACK,
|
|
17
|
-
TITLE_TRUNCATE_LENGTH,
|
|
18
|
-
} from "./constants";
|
|
16
|
+
import { COMPANY_NAME, MONO_FONT_STACK, SUPPORT_EMAIL } from "./constants";
|
|
19
17
|
import { escapeHtml } from "./escape-html";
|
|
20
18
|
|
|
21
19
|
const MONO = `font-family: ${MONO_FONT_STACK};`;
|
|
22
20
|
|
|
23
21
|
// =============================================================================
|
|
24
|
-
//
|
|
22
|
+
// Core types
|
|
25
23
|
// =============================================================================
|
|
26
24
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
<html lang="en">
|
|
34
|
-
<head><meta charset="UTF-8"></head>
|
|
35
|
-
<body style="${MONO} color: #1a1a1a; margin: 0; padding: 0;">
|
|
36
|
-
${inner}
|
|
37
|
-
</body>
|
|
38
|
-
</html>`;
|
|
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;
|
|
39
31
|
}
|
|
40
32
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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>;
|
|
44
41
|
|
|
45
|
-
/**
|
|
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. */
|
|
46
61
|
export type Spacing = "normal" | "tight" | "none";
|
|
47
62
|
const SPACING: Record<Spacing, string> = {
|
|
48
63
|
normal: "0 0 20px 0",
|
|
@@ -50,50 +65,98 @@ const SPACING: Record<Spacing, string> = {
|
|
|
50
65
|
none: "0",
|
|
51
66
|
};
|
|
52
67
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
// =============================================================================
|
|
69
|
+
// Shells
|
|
70
|
+
// =============================================================================
|
|
71
|
+
|
|
72
|
+
/** Wrap body blocks in the shared `<!DOCTYPE>` monospace shell. */
|
|
73
|
+
export function htmlShell(blocks: Block[]): string {
|
|
74
|
+
const inner = blocks
|
|
75
|
+
.map((b) => b.html)
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
.join("\n");
|
|
78
|
+
return `<!DOCTYPE html>
|
|
79
|
+
<html lang="en">
|
|
80
|
+
<head><meta charset="UTF-8"></head>
|
|
81
|
+
<body style="${MONO} color: #1a1a1a; margin: 0; padding: 0;">
|
|
82
|
+
${inner}
|
|
83
|
+
</body>
|
|
84
|
+
</html>`;
|
|
56
85
|
}
|
|
57
86
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
)
|
|
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";
|
|
63
96
|
}
|
|
64
97
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
98
|
+
// =============================================================================
|
|
99
|
+
// Paragraphs
|
|
100
|
+
// =============================================================================
|
|
101
|
+
|
|
102
|
+
/**
|
|
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.
|
|
106
|
+
*/
|
|
107
|
+
export function paragraph(
|
|
108
|
+
content: InlineContent,
|
|
68
109
|
spacing: Spacing = "normal",
|
|
69
|
-
):
|
|
70
|
-
|
|
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
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** "Hi Name," / "Hi," greeting. */
|
|
120
|
+
export function greeting(recipientName?: string): Block {
|
|
121
|
+
return paragraph(recipientName ? `Hi ${recipientName},` : "Hi,");
|
|
71
122
|
}
|
|
72
123
|
|
|
73
|
-
/** "Label:
|
|
74
|
-
export function
|
|
124
|
+
/** "Label: **value**" key/value row (value bold in HTML; tight by default). */
|
|
125
|
+
export function keyValue(
|
|
75
126
|
label: string,
|
|
76
|
-
|
|
127
|
+
value: string,
|
|
77
128
|
spacing: Spacing = "tight",
|
|
78
|
-
):
|
|
79
|
-
return
|
|
129
|
+
): Block {
|
|
130
|
+
return paragraph([`${label}: `, bold(value)], spacing);
|
|
80
131
|
}
|
|
81
132
|
|
|
82
|
-
/** Footer paragraph: first line + optional second line after a
|
|
133
|
+
/** Footer paragraph: first line + optional second line after a line break. */
|
|
83
134
|
export function footer(
|
|
84
135
|
firstLine: string,
|
|
85
136
|
secondLine?: string,
|
|
86
137
|
spacing: Spacing = "normal",
|
|
87
|
-
):
|
|
88
|
-
|
|
89
|
-
|
|
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,
|
|
90
146
|
spacing,
|
|
91
|
-
|
|
147
|
+
};
|
|
92
148
|
}
|
|
93
149
|
|
|
94
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
};
|
|
97
160
|
}
|
|
98
161
|
|
|
99
162
|
/** The standard "no action required" reassurance line (content constant). */
|
|
@@ -108,19 +171,24 @@ export const ACCESS_PHRASE: Record<"editor" | "commenter" | "viewer", string> =
|
|
|
108
171
|
};
|
|
109
172
|
|
|
110
173
|
/** Centered continuation dots separator (chat-shared). */
|
|
111
|
-
export function chatDots():
|
|
112
|
-
return
|
|
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
|
+
};
|
|
113
180
|
}
|
|
114
181
|
|
|
115
182
|
// =============================================================================
|
|
116
|
-
//
|
|
183
|
+
// CTA box
|
|
117
184
|
// =============================================================================
|
|
118
185
|
|
|
119
186
|
/** Options for the `>> LABEL <<` call-to-action box. */
|
|
120
187
|
export interface CtaBoxOptions {
|
|
121
188
|
/** Text between the chevrons (already display-safe). */
|
|
122
189
|
label: string;
|
|
123
|
-
/** 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). */
|
|
124
192
|
href?: string;
|
|
125
193
|
/** Inner-cell padding, e.g. `"16px 24px"`. */
|
|
126
194
|
padding: string;
|
|
@@ -134,8 +202,20 @@ export interface CtaBoxOptions {
|
|
|
134
202
|
letterSpacing?: string;
|
|
135
203
|
}
|
|
136
204
|
|
|
137
|
-
/**
|
|
138
|
-
|
|
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 {
|
|
139
219
|
const {
|
|
140
220
|
label,
|
|
141
221
|
href,
|
|
@@ -156,153 +236,124 @@ export function ctaBox(opts: CtaBoxOptions): string {
|
|
|
156
236
|
`padding: ${padding}; text-align: center; ${MONO} font-size: ${fontSize};` +
|
|
157
237
|
(letterSpacing ? ` letter-spacing: ${letterSpacing};` : ``);
|
|
158
238
|
|
|
159
|
-
const chevrons = `<b>>> ${label} <<</b>`;
|
|
239
|
+
const chevrons = `<b>>> ${escapeHtml(label)} <<</b>`;
|
|
160
240
|
const inner = href
|
|
161
241
|
? `<a href="${href}" style="color: #0047FF; text-decoration: underline;">${chevrons}</a>`
|
|
162
242
|
: chevrons;
|
|
163
243
|
|
|
164
|
-
|
|
244
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" style="${tableStyle}">
|
|
165
245
|
<tr><td style="${tdStyle}">
|
|
166
246
|
${inner}
|
|
167
247
|
</td></tr>
|
|
168
248
|
</table>`;
|
|
169
|
-
}
|
|
170
249
|
|
|
171
|
-
|
|
172
|
-
// HTML — quote block
|
|
173
|
-
// =============================================================================
|
|
250
|
+
const text = href ? `${asciiCtaBox(label)}\n\n${href}` : asciiCtaBox(label);
|
|
174
251
|
|
|
175
|
-
|
|
176
|
-
export function quoteBlock(labelHtml: string, bodyHtml: string): string {
|
|
177
|
-
return `<table cellpadding="0" cellspacing="0" border="0" style="margin: 0 0 20px 0;">
|
|
178
|
-
<tr><td style="border-left: 2px solid #1a1a1a; padding: 4px 14px; ${MONO} font-size: 13px; background: #f5f5f5;">
|
|
179
|
-
<span style="color: #555;">${labelHtml}</span><br>${bodyHtml}
|
|
180
|
-
</td></tr>
|
|
181
|
-
</table>`;
|
|
252
|
+
return { html, text, spacing: "normal" };
|
|
182
253
|
}
|
|
183
254
|
|
|
184
255
|
// =============================================================================
|
|
185
|
-
//
|
|
256
|
+
// Chat bubbles — one dual-output block per role
|
|
186
257
|
// =============================================================================
|
|
187
258
|
|
|
188
|
-
/**
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
259
|
+
/** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
|
|
260
|
+
function wrapText(text: string, width: number): string[] {
|
|
261
|
+
const lines: string[] = [];
|
|
262
|
+
let cur = "";
|
|
263
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
264
|
+
let w = word;
|
|
265
|
+
while (w.length > width) {
|
|
266
|
+
if (cur) {
|
|
267
|
+
lines.push(cur);
|
|
268
|
+
cur = "";
|
|
269
|
+
}
|
|
270
|
+
lines.push(w.slice(0, width));
|
|
271
|
+
w = w.slice(width);
|
|
272
|
+
}
|
|
273
|
+
if (!cur) cur = w;
|
|
274
|
+
else if (cur.length + 1 + w.length <= width) cur += ` ${w}`;
|
|
275
|
+
else {
|
|
276
|
+
lines.push(cur);
|
|
277
|
+
cur = w;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (cur) lines.push(cur);
|
|
281
|
+
return lines.length ? lines : [""];
|
|
196
282
|
}
|
|
197
283
|
|
|
198
|
-
/**
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
284
|
+
/** Chat message truncation budget: at most MAX_MESSAGE_LINES lines of MESSAGE_WIDTH chars. */
|
|
285
|
+
const MAX_MESSAGE_LINES = 3;
|
|
286
|
+
const MESSAGE_WIDTH = 35;
|
|
287
|
+
|
|
288
|
+
/** Word-wrap `text`, then clamp to `maxLines`, ellipsizing the last line on overflow. */
|
|
289
|
+
function wrapClamped(text: string, width: number, maxLines: number): string[] {
|
|
290
|
+
const lines = wrapText(text, width);
|
|
291
|
+
if (lines.length <= maxLines) return lines;
|
|
292
|
+
const kept = lines.slice(0, maxLines);
|
|
293
|
+
const last = kept[maxLines - 1];
|
|
294
|
+
kept[maxLines - 1] =
|
|
295
|
+
(last.length > width - 3 ? last.slice(0, width - 3).trimEnd() : last) +
|
|
296
|
+
"...";
|
|
297
|
+
return kept;
|
|
206
298
|
}
|
|
207
299
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
function centered(value: string, width: number): string {
|
|
216
|
-
const pad = Math.max(0, width - value.length);
|
|
217
|
-
const left = Math.floor(pad / 2);
|
|
218
|
-
const right = pad - left;
|
|
219
|
-
return `${" ".repeat(left)}${value}${" ".repeat(right)}`;
|
|
300
|
+
/**
|
|
301
|
+
* The one truncation authority: clamp a raw message to MAX_MESSAGE_LINES ×
|
|
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.
|
|
304
|
+
*/
|
|
305
|
+
function clampMessage(text: string): string {
|
|
306
|
+
return wrapClamped(text, MESSAGE_WIDTH, MAX_MESSAGE_LINES).join(" ");
|
|
220
307
|
}
|
|
221
308
|
|
|
222
|
-
/**
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
`|${centered(line, CTA_BOX_WIDTH)}|`,
|
|
231
|
-
blank,
|
|
232
|
-
border,
|
|
233
|
-
].join("\n");
|
|
234
|
-
}
|
|
309
|
+
/**
|
|
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.
|
|
314
|
+
*/
|
|
315
|
+
export function chatUser(content: string, from?: string): Block {
|
|
316
|
+
const clamped = clampMessage(content);
|
|
235
317
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
border
|
|
246
|
-
|
|
247
|
-
}
|
|
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>`;
|
|
248
331
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
332
|
+
const border = "─".repeat(MESSAGE_WIDTH + 1);
|
|
333
|
+
const lines = wrapText(clamped, MESSAGE_WIDTH);
|
|
334
|
+
const body = lines.map((line) => `│${line.padStart(MESSAGE_WIDTH)} │`);
|
|
335
|
+
const box = [`┌${border}┐`, ...body, `└${border}┘ (•̀_ರ╮)`];
|
|
336
|
+
if (from) box.push(from.padStart(MESSAGE_WIDTH + 3));
|
|
252
337
|
|
|
253
|
-
|
|
254
|
-
export function textQuote(label: string, value: string): string {
|
|
255
|
-
return `${label}:\n"${value}"`;
|
|
338
|
+
return { html, text: box.join("\n"), spacing: "normal" };
|
|
256
339
|
}
|
|
257
340
|
|
|
258
|
-
/**
|
|
259
|
-
export function
|
|
260
|
-
|
|
261
|
-
}
|
|
341
|
+
/** Left-aligned assistant chat message with `[c_S]` avatar (bottom-aligned). */
|
|
342
|
+
export function chatAssistant(content: string): Block {
|
|
343
|
+
const clamped = clampMessage(content);
|
|
262
344
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const padded = truncated.padStart(35);
|
|
270
|
-
return `┌────────────────────────────────────┐
|
|
271
|
-
│${padded} │ (•̀_ರ╮)
|
|
272
|
-
└────────────────────────────────────┘`;
|
|
273
|
-
}
|
|
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>`;
|
|
274
351
|
|
|
275
|
-
/** Left-aligned assistant chat box with `[c_S]` label (max 2 lines, 36 wide). */
|
|
276
|
-
export function chatAssistantBoxText(text: string): string {
|
|
277
352
|
const lineWidth = 36;
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
let truncated = text;
|
|
281
|
-
if (text.length > maxChars - 3) {
|
|
282
|
-
truncated = text.slice(0, maxChars - 3) + "...";
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const lines: string[] = [];
|
|
286
|
-
let remaining = truncated;
|
|
287
|
-
while (remaining.length > 0 && lines.length < 2) {
|
|
288
|
-
if (remaining.length <= lineWidth) {
|
|
289
|
-
lines.push(remaining);
|
|
290
|
-
remaining = "";
|
|
291
|
-
} else {
|
|
292
|
-
let breakPoint = remaining.lastIndexOf(" ", lineWidth);
|
|
293
|
-
if (breakPoint <= 0) breakPoint = lineWidth;
|
|
294
|
-
lines.push(remaining.slice(0, breakPoint));
|
|
295
|
-
remaining = remaining.slice(breakPoint).trimStart();
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
353
|
+
const lines = wrapText(clamped, lineWidth);
|
|
299
354
|
const top = " ┌──────────────────────────────────────┐";
|
|
300
|
-
const bot = "
|
|
301
|
-
const boxLines = lines.map((line
|
|
302
|
-
const padded = ` ${line.padEnd(lineWidth)} `;
|
|
303
|
-
const label = i === 0 ? "[c_S] " : " ";
|
|
304
|
-
return `${label}│${padded}│`;
|
|
305
|
-
});
|
|
355
|
+
const bot = "[c_S] └──────────────────────────────────────┘";
|
|
356
|
+
const boxLines = lines.map((line) => ` │ ${line.padEnd(lineWidth)} │`);
|
|
306
357
|
|
|
307
|
-
return [top, ...boxLines, bot].join("\n");
|
|
358
|
+
return { html, text: [top, ...boxLines, bot].join("\n"), spacing: "normal" };
|
|
308
359
|
}
|