@company-semantics/contracts 27.12.0 → 27.13.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.
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Locks the rendered output (subject / text / html) of every email kind ×
3
+ * representative variant. This is production-parity email markup — a snapshot
4
+ * change means a real sent email changed. Review the diff, then `vitest -u`.
5
+ */
6
+
7
+ import { describe, expect, it } from "vitest";
8
+
9
+ import type { EmailPayloads } from "../../types";
10
+ import {
11
+ IMPLEMENTED_EMAIL_KINDS,
12
+ type ImplementedEmailKind,
13
+ type RenderEmailOptions,
14
+ type RenderedEmail,
15
+ renderEmail,
16
+ } from "../index";
17
+
18
+ interface Fixture {
19
+ kind: ImplementedEmailKind;
20
+ name: string;
21
+ render: () => RenderedEmail;
22
+ }
23
+
24
+ const fixtures: Fixture[] = [];
25
+ function add<K extends ImplementedEmailKind>(
26
+ kind: K,
27
+ name: string,
28
+ payload: EmailPayloads[K],
29
+ options?: RenderEmailOptions,
30
+ ): void {
31
+ fixtures.push({
32
+ kind,
33
+ name,
34
+ render: () => renderEmail(kind, payload, options),
35
+ });
36
+ }
37
+
38
+ const APP = "https://app.companysemantics.ai";
39
+
40
+ add("auth.otp", "Default", { otp: "123456", expiresInMinutes: 10 });
41
+ add("auth.otp", "Short expiry", { otp: "902413", expiresInMinutes: 1 });
42
+ add(
43
+ "auth.otp",
44
+ "With request metadata",
45
+ {
46
+ otp: "246810",
47
+ expiresInMinutes: 5,
48
+ requestIp: "203.0.113.4",
49
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
50
+ },
51
+ { includeRequestMetadata: true },
52
+ );
53
+
54
+ add("org.invite", "Admin", {
55
+ inviterName: "Alex Rivera",
56
+ orgName: "Acme Corp",
57
+ role: "admin",
58
+ acceptUrl: `${APP}/invite/abc123`,
59
+ expiresInDays: 7,
60
+ });
61
+ add("org.invite", "Member", {
62
+ inviterName: "Alex Rivera",
63
+ orgName: "Acme Corp",
64
+ role: "member",
65
+ acceptUrl: `${APP}/invite/def456`,
66
+ expiresInDays: 7,
67
+ });
68
+
69
+ add("org.unit_owner_granted", "Unit owner + message", {
70
+ granterName: "Jordan Lee",
71
+ recipientName: "Sam Chen",
72
+ unitName: "Platform Engineering",
73
+ roleLabel: "Unit owner",
74
+ ctaUrl: `${APP}/org/unit/42`,
75
+ message: "Welcome aboard — glad to have you owning this team.",
76
+ });
77
+ add("org.unit_owner_granted", "Delegate, no message", {
78
+ granterName: "Jordan Lee",
79
+ unitName: "Platform Engineering",
80
+ roleLabel: "Delegate",
81
+ ctaUrl: `${APP}/org/unit/42`,
82
+ });
83
+
84
+ add("org.ownership_transfer", "With note", {
85
+ orgName: "Acme Corp",
86
+ acceptUrl: `${APP}/owner-transfer/accept/tok123`,
87
+ expiresInDays: 14,
88
+ note: "Handing this over as I move to an advisory role.",
89
+ });
90
+ add("org.ownership_transfer", "No note", {
91
+ orgName: "Acme Corp",
92
+ acceptUrl: `${APP}/owner-transfer/accept/tok456`,
93
+ expiresInDays: 14,
94
+ });
95
+
96
+ add("org.ownership_transfer_completed", "Default", {
97
+ orgName: "Acme Corp",
98
+ newOwnerEmail: "new.owner@acme.com",
99
+ });
100
+
101
+ add("security.alert", "Excessive OTP requests", {
102
+ alertType: "excessive_otp_requests",
103
+ details: "12 login codes requested in 5 minutes",
104
+ timestamp: "2026-07-12T14:30:00Z",
105
+ });
106
+ add("security.alert", "Unusual login location", {
107
+ alertType: "unusual_login_location",
108
+ details: "Login from Berlin, DE",
109
+ timestamp: "2026-07-12T14:30:00Z",
110
+ });
111
+
112
+ const CHAT_BASE = {
113
+ sharedByName: "Alex Rivera",
114
+ visibility: "private",
115
+ } as const;
116
+ add("chat.shared", "Default", {
117
+ ...CHAT_BASE,
118
+ chatTitle: "API design discussion",
119
+ shareUrl: `${APP}/share/abc123`,
120
+ previewText:
121
+ "Here are my thoughts on the REST API design for the new authentication endpoints.",
122
+ });
123
+ add("chat.shared", "No preview", {
124
+ ...CHAT_BASE,
125
+ chatTitle: "Quick sync notes",
126
+ shareUrl: `${APP}/share/xyz789`,
127
+ });
128
+ add("chat.shared", "Long title", {
129
+ ...CHAT_BASE,
130
+ visibility: "public",
131
+ chatTitle:
132
+ "This is a very long chat title that should be truncated to fit the layout properly",
133
+ shareUrl: `${APP}/share/long456`,
134
+ previewText: "The assistant response preview text.",
135
+ });
136
+ add("chat.shared", "Long preview", {
137
+ ...CHAT_BASE,
138
+ chatTitle: "Code review feedback",
139
+ shareUrl: `${APP}/share/preview789`,
140
+ previewText:
141
+ "I reviewed the pull request and found several areas that need improvement. The authentication logic needs better error handling, and the database queries could be optimized.",
142
+ });
143
+ add("chat.shared", "Short", {
144
+ ...CHAT_BASE,
145
+ chatTitle: "Hi",
146
+ shareUrl: `${APP}/share/short123`,
147
+ previewText: "Hello!",
148
+ });
149
+
150
+ add("share.granted", "Document · editor · message", {
151
+ granterName: "Jordan Lee",
152
+ recipientName: "Sam Chen",
153
+ entityLabel: "document",
154
+ entityTitle: "Q3 Roadmap",
155
+ accessLevel: "editor",
156
+ ctaUrl: `${APP}/doc/roadmap`,
157
+ message: "Take a look before Thursday's planning.",
158
+ });
159
+ add("share.granted", "Meeting · viewer · no title", {
160
+ granterName: "Jordan Lee",
161
+ entityLabel: "meeting",
162
+ accessLevel: "viewer",
163
+ ctaUrl: `${APP}/meeting/8842`,
164
+ });
165
+
166
+ add("companyMd.access_requested", "With message", {
167
+ requesterName: "Sam Chen",
168
+ docTitle: "Engineering Handbook",
169
+ message: "I'd like to contribute the onboarding section.",
170
+ reviewUrl: `${APP}/doc/handbook?request=req_123`,
171
+ });
172
+ add("companyMd.access_requested", "No message", {
173
+ requesterName: "Sam Chen",
174
+ docTitle: "Engineering Handbook",
175
+ reviewUrl: `${APP}/doc/handbook?request=req_456`,
176
+ });
177
+
178
+ add("companyMd.access_request_approved", "Editor", {
179
+ approverName: "Jordan Lee",
180
+ docTitle: "Engineering Handbook",
181
+ accessLevel: "editor",
182
+ docUrl: `${APP}/doc/handbook`,
183
+ });
184
+ add("companyMd.access_request_approved", "Viewer", {
185
+ approverName: "Jordan Lee",
186
+ docTitle: "Engineering Handbook",
187
+ accessLevel: "viewer",
188
+ docUrl: `${APP}/doc/handbook`,
189
+ });
190
+
191
+ add("companyMd.access_request_denied", "With reason", {
192
+ approverName: "Jordan Lee",
193
+ docTitle: "Engineering Handbook",
194
+ reason: "This doc is limited to the platform team for now.",
195
+ });
196
+ add("companyMd.access_request_denied", "No reason", {
197
+ approverName: "Jordan Lee",
198
+ docTitle: "Engineering Handbook",
199
+ });
200
+
201
+ describe("email render", () => {
202
+ it("has a fixture for every implemented kind", () => {
203
+ const covered = new Set(fixtures.map((f) => f.kind));
204
+ for (const kind of IMPLEMENTED_EMAIL_KINDS) {
205
+ expect(covered.has(kind), `missing fixture for ${kind}`).toBe(true);
206
+ }
207
+ });
208
+
209
+ for (const fixture of fixtures) {
210
+ it(`${fixture.kind} · ${fixture.name}`, () => {
211
+ expect(fixture.render()).toMatchSnapshot();
212
+ });
213
+ }
214
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Auth OTP email (login code). Plain text + HTML.
3
+ *
4
+ * INVARIANT: the OTP value is a runtime payload field — never a literal in this
5
+ * package. Request metadata (IP / user agent) is gated by the caller-supplied
6
+ * `includeRequestMetadata` option (backend reads its env flag and passes it).
7
+ */
8
+
9
+ import type { EmailPayloads } from "../types";
10
+
11
+ import { baseTextLayout } from "./base-text";
12
+ import {
13
+ asciiCodeBox,
14
+ bodyParagraph,
15
+ ctaBox,
16
+ footer,
17
+ htmlShell,
18
+ metaRow,
19
+ monoParagraph,
20
+ signatureLine,
21
+ } from "./blocks";
22
+ import { COMPANY_NAME } from "./constants";
23
+
24
+ export type AuthOtpPayload = EmailPayloads["auth.otp"];
25
+
26
+ export interface RenderOptions {
27
+ /** Include request IP / device details (PII; opt-in). */
28
+ includeRequestMetadata?: boolean;
29
+ }
30
+
31
+ export function renderAuthOtpBody(
32
+ payload: AuthOtpPayload,
33
+ options?: RenderOptions,
34
+ ): string {
35
+ const { otp, expiresInMinutes, requestIp, userAgent } = payload;
36
+
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:"),
71
+ ctaBox({
72
+ label: otp,
73
+ padding: "20px 24px",
74
+ fontSize: "20px",
75
+ borderRadiusZero: true,
76
+ letterSpacing: "4px",
77
+ }),
78
+ metaRow("Status", "VALID", "tight"),
79
+ metaRow(
80
+ "Expires in",
81
+ `${expiresInMinutes} ${expiresInMinutes === 1 ? "min" : "mins"}`,
82
+ "normal",
83
+ ),
84
+ footer(
85
+ `This code was generated to authorize a login to ${COMPANY_NAME}.`,
86
+ "It expires automatically and cannot be reused.",
87
+ ),
88
+ monoParagraph("If this wasn't you, no action is required."),
89
+ signatureLine(),
90
+ ]);
91
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Plain-text email layout.
3
+ */
4
+
5
+ import { COMPANY_NAME, SUPPORT_EMAIL } from "./constants";
6
+
7
+ /**
8
+ * Wrap plain-text email content in the shared footer
9
+ * (`--- / <signer> / Questions? Contact <support>`).
10
+ *
11
+ * @param content - Body content (without footer); trimmed.
12
+ * @param signer - Footer signer name; defaults to the company name.
13
+ * (`security.alert` passes "Company Semantics Security".)
14
+ */
15
+ export function baseTextLayout(
16
+ content: string,
17
+ signer: string = COMPANY_NAME,
18
+ ): string {
19
+ const footer = `
20
+ ---
21
+ ${signer}
22
+ Questions? Contact ${SUPPORT_EMAIL}
23
+ `;
24
+
25
+ return content.trim() + "\n" + footer.trim() + "\n";
26
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Shared email building blocks.
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 email — HTML and plain text — across the
7
+ * backend (real sends) and the app (Ladle preview).
8
+ *
9
+ * INVARIANTS:
10
+ * - Pure string functions, no side effects.
11
+ * - Callers pass already-escaped content for any user-controlled HTML field.
12
+ */
13
+
14
+ import {
15
+ COMPANY_NAME,
16
+ MONO_FONT_STACK,
17
+ TITLE_TRUNCATE_LENGTH,
18
+ } from "./constants";
19
+ import { escapeHtml } from "./escape-html";
20
+
21
+ const MONO = `font-family: ${MONO_FONT_STACK};`;
22
+
23
+ // =============================================================================
24
+ // HTML — shell
25
+ // =============================================================================
26
+
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>`;
39
+ }
40
+
41
+ // =============================================================================
42
+ // HTML — paragraphs
43
+ // =============================================================================
44
+
45
+ /** Vertical spacing options for a mono paragraph. */
46
+ export type Spacing = "normal" | "tight" | "none";
47
+ const SPACING: Record<Spacing, string> = {
48
+ normal: "0 0 20px 0",
49
+ tight: "0 0 4px 0",
50
+ none: "0",
51
+ };
52
+
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>`;
56
+ }
57
+
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
+ );
63
+ }
64
+
65
+ /** Monospace paragraph for meta / footer / notice lines. */
66
+ export function monoParagraph(
67
+ html: string,
68
+ spacing: Spacing = "normal",
69
+ ): string {
70
+ return `<p style="${MONO} font-size: 14px; margin: ${SPACING[spacing]};">${html}</p>`;
71
+ }
72
+
73
+ /** "Label: <b>value</b>" metadata row. */
74
+ export function metaRow(
75
+ label: string,
76
+ valueHtml: string,
77
+ spacing: Spacing = "tight",
78
+ ): string {
79
+ return monoParagraph(`${label}: <b>${valueHtml}</b>`, spacing);
80
+ }
81
+
82
+ /** Footer paragraph: first line + optional second line after a `<br>`. */
83
+ export function footer(
84
+ firstLine: string,
85
+ secondLine?: string,
86
+ spacing: Spacing = "normal",
87
+ ): string {
88
+ return monoParagraph(
89
+ secondLine ? `${firstLine}<br>${secondLine}` : firstLine,
90
+ spacing,
91
+ );
92
+ }
93
+
94
+ /** Trailing company signature paragraph. */
95
+ export function signatureLine(): string {
96
+ return monoParagraph(COMPANY_NAME, "none");
97
+ }
98
+
99
+ /** The standard "no action required" reassurance line (content constant). */
100
+ export const NOTICE = "If you weren't expecting this, no action is required.";
101
+
102
+ /** Human phrasing per access level (share-granted, access-approved). */
103
+ export const ACCESS_PHRASE: Record<"editor" | "commenter" | "viewer", string> =
104
+ {
105
+ editor: "can edit",
106
+ commenter: "can comment",
107
+ viewer: "can view",
108
+ };
109
+
110
+ /** 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>`;
113
+ }
114
+
115
+ // =============================================================================
116
+ // HTML — CTA box
117
+ // =============================================================================
118
+
119
+ /** Options for the `>> LABEL <<` call-to-action box. */
120
+ export interface CtaBoxOptions {
121
+ /** Text between the chevrons (already display-safe). */
122
+ label: string;
123
+ /** When present, the label links to this URL. */
124
+ href?: string;
125
+ /** Inner-cell padding, e.g. `"16px 24px"`. */
126
+ padding: string;
127
+ /** Label font-size, e.g. `"16px"`. */
128
+ fontSize: string;
129
+ /** Optional table `max-width`, e.g. `"220px"`. */
130
+ maxWidth?: string;
131
+ /** Emit `border-radius: 0;` (OTP code box). */
132
+ borderRadiusZero?: boolean;
133
+ /** Optional `letter-spacing`, e.g. `"4px"` (OTP code box). */
134
+ letterSpacing?: string;
135
+ }
136
+
137
+ /** The bordered `>> LABEL <<` CTA box (OTP / JOIN / OPEN / VIEW TEAM / …). */
138
+ export function ctaBox(opts: CtaBoxOptions): string {
139
+ const {
140
+ label,
141
+ href,
142
+ padding,
143
+ fontSize,
144
+ maxWidth,
145
+ borderRadiusZero,
146
+ letterSpacing,
147
+ } = opts;
148
+
149
+ const tableStyle =
150
+ `border: 2px solid #1a1a1a;` +
151
+ (borderRadiusZero ? ` border-radius: 0;` : ``) +
152
+ ` margin: 0 0 20px 0;` +
153
+ (maxWidth ? ` max-width: ${maxWidth};` : ``);
154
+
155
+ const tdStyle =
156
+ `padding: ${padding}; text-align: center; ${MONO} font-size: ${fontSize};` +
157
+ (letterSpacing ? ` letter-spacing: ${letterSpacing};` : ``);
158
+
159
+ const chevrons = `<b>&gt;&gt; ${label} &lt;&lt;</b>`;
160
+ const inner = href
161
+ ? `<a href="${href}" style="color: #0047FF; text-decoration: underline;">${chevrons}</a>`
162
+ : chevrons;
163
+
164
+ return `<table cellpadding="0" cellspacing="0" border="0" style="${tableStyle}">
165
+ <tr><td style="${tdStyle}">
166
+ ${inner}
167
+ </td></tr>
168
+ </table>`;
169
+ }
170
+
171
+ // =============================================================================
172
+ // HTML — quote block
173
+ // =============================================================================
174
+
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>`;
182
+ }
183
+
184
+ // =============================================================================
185
+ // HTML — chat bubbles
186
+ // =============================================================================
187
+
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>`;
196
+ }
197
+
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>`;
206
+ }
207
+
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)}`;
220
+ }
221
+
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
+ }
235
+
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
+ }
248
+
249
+ // =============================================================================
250
+ // Plain text — quote + chat boxes
251
+ // =============================================================================
252
+
253
+ /** Plain-text quoted message: `Label:` then the quoted value. */
254
+ export function textQuote(label: string, value: string): string {
255
+ return `${label}:\n"${value}"`;
256
+ }
257
+
258
+ /** Plain-text continuation dots, centered under the 38-char chat box. */
259
+ export function chatDotsText(): string {
260
+ return " ⋮";
261
+ }
262
+
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
+ }
274
+
275
+ /** Left-aligned assistant chat box with `[c_S]` label (max 2 lines, 36 wide). */
276
+ export function chatAssistantBoxText(text: string): string {
277
+ 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
+
299
+ 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
+ });
306
+
307
+ return [top, ...boxLines, bot].join("\n");
308
+ }