@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.
@@ -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,16 +8,13 @@
8
8
 
9
9
  import type { EmailPayloads } from "../types";
10
10
 
11
- import { baseTextLayout } from "./base-text";
12
11
  import {
13
- asciiCodeBox,
14
- bodyParagraph,
12
+ type Block,
15
13
  ctaBox,
16
14
  footer,
17
- htmlShell,
18
- metaRow,
19
- monoParagraph,
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 renderAuthOtpBody(
28
+ export function renderAuthOtp(
32
29
  payload: AuthOtpPayload,
33
30
  options?: RenderOptions,
34
- ): string {
31
+ ): Block[] {
35
32
  const { otp, expiresInMinutes, requestIp, userAgent } = payload;
36
33
 
37
- const sections: string[] = [];
38
- sections.push("Your login code is below.");
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
- metaRow("Status", "VALID", "tight"),
79
- metaRow(
43
+ keyValue("Status", "VALID"),
44
+ keyValue(
80
45
  "Expires in",
81
- `${expiresInMinutes} ${expiresInMinutes === 1 ? "min" : "mins"}`,
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
- monoParagraph("If this wasn't you, no action is required."),
89
- signatureLine(),
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
- * 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 {
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
- // HTML — shell
22
+ // Core types
25
23
  // =============================================================================
26
24
 
27
- /** Wrap body blocks in the shared `<!DOCTYPE>` monospace shell. */
28
- export function htmlShell(blocks: string | string[]): string {
29
- const inner = Array.isArray(blocks)
30
- ? blocks.filter(Boolean).join("\n")
31
- : blocks;
32
- return `<!DOCTYPE html>
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
- // HTML paragraphs
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
- /** Vertical spacing options for a mono paragraph. */
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
- /** Intro / greeting / body paragraph (inherits the body monospace). */
54
- export function bodyParagraph(html: string): string {
55
- return `<p style="margin: 0 0 20px 0; font-size: 14px;">${html}</p>`;
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
- /** "Hi Name," / "Hi," greeting. Escapes the name. */
59
- export function greeting(recipientName?: string): string {
60
- return bodyParagraph(
61
- recipientName ? `Hi ${escapeHtml(recipientName)},` : "Hi,",
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
- /** Monospace paragraph for meta / footer / notice lines. */
66
- export function monoParagraph(
67
- html: string,
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
- ): string {
70
- return `<p style="${MONO} font-size: 14px; margin: ${SPACING[spacing]};">${html}</p>`;
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: <b>value</b>" metadata row. */
74
- export function metaRow(
124
+ /** "Label: **value**" key/value row (value bold in HTML; tight by default). */
125
+ export function keyValue(
75
126
  label: string,
76
- valueHtml: string,
127
+ value: string,
77
128
  spacing: Spacing = "tight",
78
- ): string {
79
- return monoParagraph(`${label}: <b>${valueHtml}</b>`, spacing);
129
+ ): Block {
130
+ return paragraph([`${label}: `, bold(value)], spacing);
80
131
  }
81
132
 
82
- /** Footer paragraph: first line + optional second line after a `<br>`. */
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
- ): string {
88
- return monoParagraph(
89
- 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,
90
146
  spacing,
91
- );
147
+ };
92
148
  }
93
149
 
94
- /** Trailing company signature paragraph. */
95
- export function signatureLine(): string {
96
- return monoParagraph(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
+ };
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(): string {
112
- 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
+ };
113
180
  }
114
181
 
115
182
  // =============================================================================
116
- // HTML — CTA box
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
- /** The bordered `>> LABEL <<` CTA box (OTP / JOIN / OPEN / VIEW TEAM / …). */
138
- 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 {
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>&gt;&gt; ${label} &lt;&lt;</b>`;
239
+ const chevrons = `<b>&gt;&gt; ${escapeHtml(label)} &lt;&lt;</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
- return `<table cellpadding="0" cellspacing="0" border="0" style="${tableStyle}">
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
- /** The left-ruled quoted-message block. Both args must be pre-escaped. */
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
- // HTMLchat bubbles
256
+ // Chat bubbles one dual-output block per role
186
257
  // =============================================================================
187
258
 
188
- /** Right-aligned user chat bubble. `contentHtml` must be escaped + truncated. */
189
- export function chatUserBubble(contentHtml: string): string {
190
- return `<table cellpadding="0" cellspacing="0" border="0" style="margin: 0 0 16px 0;">
191
- <tr>
192
- <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>
193
- <td style="${MONO} font-size: 14px; padding-left: 8px; vertical-align: bottom;">(•̀_ರ╮)</td>
194
- </tr>
195
- </table>`;
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
- /** Left-aligned assistant chat bubble. `contentHtml` must be escaped + truncated. */
199
- export function chatAssistantBubble(contentHtml: string): string {
200
- return `<table cellpadding="0" cellspacing="0" border="0" style="margin: 16px 0;">
201
- <tr>
202
- <td style="${MONO} font-size: 14px; padding-right: 8px; vertical-align: top;">[c_S]</td>
203
- <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>
204
- </tr>
205
- </table>`;
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
- // Plain text CTA / code boxes
210
- // =============================================================================
211
-
212
- const CTA_BOX_WIDTH = 20;
213
- const CODE_BOX_WIDTH = 18;
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
- /** Normalized `>> LABEL <<` ASCII box (`*` corners, centered). */
223
- export function asciiCtaBox(label: string): string {
224
- const line = `>> ${label} <<`;
225
- const border = `*${"-".repeat(CTA_BOX_WIDTH)}*`;
226
- const blank = `|${" ".repeat(CTA_BOX_WIDTH)}|`;
227
- return [
228
- border,
229
- blank,
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
- /** ASCII box that displays a code value (OTP; `+` corners, centered). */
237
- export function asciiCodeBox(value: string): string {
238
- const border = `+${"-".repeat(CODE_BOX_WIDTH)}+`;
239
- const blank = `|${" ".repeat(CODE_BOX_WIDTH)}|`;
240
- return [
241
- border,
242
- blank,
243
- `|${centered(value, CODE_BOX_WIDTH)}|`,
244
- blank,
245
- border,
246
- ].join("\n");
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
- // Plain text quote + chat boxes
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
- /** Plain-text quoted message: `Label:` then the quoted value. */
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
- /** Plain-text continuation dots, centered under the 38-char chat box. */
259
- export function chatDotsText(): string {
260
- return " ⋮";
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
- /** Right-aligned user chat box (title, kaomoji). */
264
- export function chatUserBoxText(title: string): string {
265
- const truncated =
266
- title.length > TITLE_TRUNCATE_LENGTH
267
- ? title.slice(0, TITLE_TRUNCATE_LENGTH - 3) + "..."
268
- : title;
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 maxChars = lineWidth * 2;
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, i) => {
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
  }