@company-semantics/contracts 31.0.0 → 33.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -58,41 +58,45 @@ add("org.invite", "Admin", {
58
58
  orgName: "Acme Corp",
59
59
  role: "admin",
60
60
  acceptUrl: `${APP}/invite/abc123`,
61
- expiresInDays: 7,
61
+ expiresAt: "2026-06-13T00:00:00.000Z",
62
62
  });
63
63
  add("org.invite", "Member", {
64
64
  inviterName: "Alex Rivera",
65
65
  orgName: "Acme Corp",
66
66
  role: "member",
67
67
  acceptUrl: `${APP}/invite/def456`,
68
- expiresInDays: 7,
68
+ expiresAt: "2026-06-13T00:00:00.000Z",
69
69
  });
70
70
 
71
71
  add("org.unit_owner_granted", "Unit owner + message", {
72
72
  granterName: "Jordan Lee",
73
73
  recipientName: "Sam Chen",
74
+ orgName: "Acme Corp",
74
75
  unitName: "Platform Engineering",
75
76
  roleLabel: "Unit owner",
76
77
  ctaUrl: `${APP}/org/unit/42`,
77
78
  message: "Welcome aboard — glad to have you owning this team.",
78
79
  });
79
- add("org.unit_owner_granted", "Delegate, no message", {
80
+ add("org.unit_owner_granted", "Delegate with expiry", {
80
81
  granterName: "Jordan Lee",
82
+ orgName: "Acme Corp",
81
83
  unitName: "Platform Engineering",
82
84
  roleLabel: "Delegate",
83
85
  ctaUrl: `${APP}/org/unit/42`,
86
+ expiresAt: "2026-06-13T00:00:00.000Z",
84
87
  });
85
88
 
86
- add("org.ownership_transfer", "With note", {
89
+ add("org.ownership_transfer", "With note + from", {
87
90
  orgName: "Acme Corp",
88
91
  acceptUrl: `${APP}/owner-transfer/accept/tok123`,
89
- expiresInDays: 14,
92
+ expiresAt: "2026-06-13T00:00:00.000Z",
90
93
  note: "Handing this over as I move to an advisory role.",
94
+ fromName: "Jordan Lee",
91
95
  });
92
- add("org.ownership_transfer", "No note", {
96
+ add("org.ownership_transfer", "No note (admin)", {
93
97
  orgName: "Acme Corp",
94
98
  acceptUrl: `${APP}/owner-transfer/accept/tok456`,
95
- expiresInDays: 14,
99
+ expiresAt: "2026-06-13T00:00:00.000Z",
96
100
  });
97
101
 
98
102
  add("org.ownership_transfer_completed", "Default", {
@@ -10,12 +10,13 @@ import type { EmailPayloads } from "../types";
10
10
 
11
11
  import {
12
12
  type Block,
13
- ctaBox,
14
13
  footer,
14
+ greeting,
15
15
  keyValue,
16
16
  paragraph,
17
17
  signature,
18
18
  } from "./blocks";
19
+ import { chatAssistant, chatCta, chatUnit } from "./chat";
19
20
  import { COMPANY_NAME } from "./constants";
20
21
 
21
22
  export type AuthOtpPayload = EmailPayloads["auth.otp"];
@@ -32,9 +33,13 @@ export function renderAuthOtp(
32
33
  const { otp, expiresInMinutes, requestIp, userAgent } = payload;
33
34
 
34
35
  const blocks: Block[] = [
35
- paragraph("Copy/paste code in login form:"),
36
- ctaBox({ label: otp, variant: "code" }),
37
- keyValue("Status", "VALID"),
36
+ greeting(),
37
+ paragraph("A login code was requested."),
38
+ chatUnit(
39
+ chatAssistant("Copy + paste this code in the login form."),
40
+ chatCta({ label: otp }),
41
+ ),
42
+ keyValue("Status", "Valid"),
38
43
  keyValue(
39
44
  "Expires in",
40
45
  `${expiresInMinutes} ${expiresInMinutes === 1 ? "minute" : "minutes"}`,
@@ -55,10 +60,10 @@ export function renderAuthOtp(
55
60
 
56
61
  blocks.push(
57
62
  footer(
58
- `This code was generated to authorize a login to ${COMPANY_NAME}.`,
59
- "It expires automatically and cannot be reused.",
63
+ `This login code was sent via ${COMPANY_NAME}.`,
64
+ "If this wasn't you, no action is required.",
65
+ "none",
60
66
  ),
61
- paragraph("If this wasn't you, no action is required."),
62
67
  signature(),
63
68
  );
64
69
 
@@ -7,8 +7,13 @@
7
7
  * UI is the components.** Editing a block restyles every email, HTML and plain
8
8
  * text, across the backend (real sends) and the app (Ladle preview).
9
9
  *
10
+ * This module holds the primitives — shells, paragraphs, and the CTA box. The
11
+ * chat unit builds on them from `./chat`; both are re-exported together from
12
+ * `./index`, which is the surface templates and consumers import.
13
+ *
10
14
  * INVARIANTS:
11
- * - Pure functions, no side effects.
15
+ * - Pure functions, no side effects — except `signature()`, which reads the
16
+ * current year (`new Date().getFullYear()`) for the copyright line.
12
17
  * - Components escape their own content; templates pass raw text (+ `bold(...)`
13
18
  * for inline emphasis). No template hand-writes markup or raw strings.
14
19
  */
@@ -16,11 +21,14 @@
16
21
  import { COMPANY_NAME, MONO_FONT_STACK } from "./constants";
17
22
  import { escapeHtml } from "./escape-html";
18
23
 
19
- const MONO = `font-family: ${MONO_FONT_STACK};`;
24
+ /** Shared type styling. Exported for `./chat` only — not part of the package
25
+ * surface (`./index` does not re-export these), so components stay the one
26
+ * place styling is decided. */
27
+ export const MONO = `font-family: ${MONO_FONT_STACK};`;
20
28
 
21
29
  /** The single font size for every email element (HTML). Plain text is monospace
22
30
  * so it carries no size — this keeps one visual size across both surfaces. */
23
- const FONT_SIZE = "13px";
31
+ export const FONT_SIZE = "13px";
24
32
 
25
33
  // =============================================================================
26
34
  // Core types
@@ -43,9 +51,17 @@ export interface Inline {
43
51
  /** Paragraph content: raw string(s) (auto-escaped for HTML) and/or `bold(...)`. */
44
52
  export type InlineContent = string | Inline | Array<string | Inline>;
45
53
 
46
- /** Bold inline emphasis (plain in text). */
54
+ /** Inline emphasis segment. Emphasis is currently visually flat — it renders
55
+ * plain (no bold) in both surfaces; the seam is kept so all inline emphasis can
56
+ * be restyled in one place. */
47
57
  export function bold(s: string): Inline {
48
- return { html: `<b>${escapeHtml(s)}</b>`, text: s };
58
+ return { html: escapeHtml(s), text: s };
59
+ }
60
+
61
+ /** Title-case a display value — capitalize the first letter of each word, e.g. a
62
+ * role like "admin" → "Admin". Leaves already-capitalized letters untouched. */
63
+ export function titleCase(s: string): string {
64
+ return s.replace(/\b\w/g, (c) => c.toUpperCase());
49
65
  }
50
66
 
51
67
  /** Normalize a segment: a raw string is escaped for HTML, passed through for text. */
@@ -83,7 +99,9 @@ export function htmlShell(blocks: Block[]): string {
83
99
  <html lang="en">
84
100
  <head><meta charset="UTF-8"></head>
85
101
  <body style="${MONO} color: #1a1a1a; margin: 0; padding: 0;">
102
+ <div style="max-width: 520px; margin: 0 auto;">
86
103
  ${inner}
104
+ </div>
87
105
  </body>
88
106
  </html>`;
89
107
  }
@@ -120,9 +138,30 @@ export function paragraph(
120
138
  };
121
139
  }
122
140
 
123
- /** "Hi Name," / "Hi," greeting. */
141
+ /** "Hi Name," greeting; falls back to "Hi there," when the name is unknown. */
124
142
  export function greeting(recipientName?: string): Block {
125
- return paragraph(recipientName ? `Hi ${recipientName},` : "Hi,");
143
+ return paragraph(recipientName ? `Hi ${recipientName},` : "Hi there,");
144
+ }
145
+
146
+ /** Security banner — the "WARNING" wordmark, sits atop security emails. */
147
+ export function security(): Block {
148
+ return paragraph("🆆🅰🆁🅽🅸🅽🅶");
149
+ }
150
+
151
+ /**
152
+ * Format an ISO timestamp as "Jun 13, 2026". Pinned to en-US + UTC so the
153
+ * output is locale-/timezone-independent and render snapshots stay
154
+ * deterministic. Returns the raw input unchanged if it is not a parseable date.
155
+ */
156
+ export function formatExpiry(iso: string): string {
157
+ const d = new Date(iso);
158
+ if (Number.isNaN(d.getTime())) return iso;
159
+ return d.toLocaleDateString("en-US", {
160
+ month: "short",
161
+ day: "numeric",
162
+ year: "numeric",
163
+ timeZone: "UTC",
164
+ });
126
165
  }
127
166
 
128
167
  /** "Label: **value**" key/value row (value bold in HTML; tight by default). */
@@ -152,12 +191,16 @@ export function footer(
152
191
  }
153
192
 
154
193
  /**
155
- * Trailing sign-off — the company (or custom `signer`) name.
194
+ * Trailing sign-off — a leading blank line, the `---` rule, then the company
195
+ * (or custom `signer`) name. Owns the blank above the rule so every sign-off is
196
+ * spaced identically; the block before it carries no trailing gap.
156
197
  */
157
198
  export function signature(signer: string = COMPANY_NAME): Block {
199
+ const line = `ⓒ ${new Date().getFullYear()} • ${signer}`;
200
+ const url = "https://companysemantics.ai";
158
201
  return {
159
- html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: ${SPACING.none};">${escapeHtml(signer)}</p>`,
160
- text: `---\n${signer}`,
202
+ 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>`,
203
+ text: `\n\n/* EOM */\n${line}\n${url}`,
161
204
  spacing: "none",
162
205
  };
163
206
  }
@@ -173,56 +216,19 @@ export const ACCESS_PHRASE: Record<"editor" | "commenter" | "viewer", string> =
173
216
  viewer: "can view",
174
217
  };
175
218
 
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
219
  // =============================================================================
186
220
  // CTA box
187
221
  // =============================================================================
188
222
 
189
- /** Options for the `>> LABEL <<` call-to-action box. */
223
+ /** Options for the `>> LABEL <<` call-to-action box. Styling is component-owned
224
+ * (the only allowable UI is the component) — callers supply only content. */
190
225
  export interface CtaBoxOptions {
191
226
  /** Text between the chevrons (already display-safe). */
192
227
  label: string;
193
228
  /** When present, the label links to this URL (and the URL rides under the
194
229
  * plain-text box). */
195
230
  href?: string;
196
- /** Visual kind. `"action"` = link CTA (default); `"code"` = copy/paste code
197
- * box (square corners, letter-spaced). */
198
- variant?: "action" | "code";
199
- }
200
-
201
- /** Component-owned styling per CTA variant. Templates supply only `label`/`href`/
202
- * `variant` — the only allowable UI is the component, so all CSS lives here,
203
- * never at the call site. */
204
- interface CtaVariantStyle {
205
- padding: string;
206
- fontSize: string;
207
- maxWidth?: string;
208
- square: boolean;
209
- letterSpacing?: string;
210
231
  }
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
- };
226
232
 
227
233
  /** Padding columns on each side of the `>> LABEL <<` line in the ASCII box. */
228
234
  const CTA_BOX_PAD = 3;
@@ -235,26 +241,25 @@ function asciiCtaBox(label: string): string {
235
241
  return [border, `|${inner}|`, border].join("\n");
236
242
  }
237
243
 
238
- /** The bordered `>> LABEL <<` CTA box (OTP / JOIN / OPEN / VIEW TEAM / …). When
239
- * `href` is given, HTML links the label and plain text prints the URL below. */
240
- export function ctaBox(opts: CtaBoxOptions): Block {
241
- const { label, href, variant = "action" } = opts;
242
- const { padding, fontSize, maxWidth, square, letterSpacing } =
243
- CTA_VARIANTS[variant];
244
-
245
- const tableStyle =
246
- `border: 2px solid #1a1a1a;` +
247
- (square ? ` border-radius: 0;` : ``) +
248
- ` margin: 0 0 20px 0;` +
249
- (maxWidth ? ` max-width: ${maxWidth};` : ``);
250
-
251
- const tdStyle =
252
- `padding: ${padding}; text-align: center; ${MONO} font-size: ${fontSize};` +
253
- (letterSpacing ? ` letter-spacing: ${letterSpacing};` : ``);
254
-
255
- const chevrons = `<b>&gt;&gt; ${escapeHtml(label)} &lt;&lt;</b>`;
244
+ /** The bordered `>> LABEL <<` button (HTML table + ascii text), with `margin` on
245
+ * the table. `ctaBox` wraps it as a standalone block; a chat unit embeds it
246
+ * (hence the export — internal to the render layer, not re-exported by `./index`). */
247
+ export function ctaButton(
248
+ opts: CtaBoxOptions,
249
+ margin: string,
250
+ ): { html: string; text: string } {
251
+ const { label, href } = opts;
252
+
253
+ const tableStyle = `display: inline-block; border: 1px solid #666; border-radius: 2px; margin: ${margin}; max-width: 220px;`;
254
+ const tdStyle = `padding: 16px 24px; text-align: center; ${MONO} font-size: ${FONT_SIZE};`;
255
+
256
+ // Underline only the label text (not the chevrons/spaces), and only when linked.
257
+ const labelHtml = href
258
+ ? `<span style="text-decoration: underline;">${escapeHtml(label)}</span>`
259
+ : escapeHtml(label);
260
+ const chevrons = `&gt;&gt; ${labelHtml} &lt;&lt;`;
256
261
  const inner = href
257
- ? `<a href="${href}" style="color: #0047FF; text-decoration: underline;">${chevrons}</a>`
262
+ ? `<a href="${href}" style="color: #0047FF; text-decoration: none;">${chevrons}</a>`
258
263
  : chevrons;
259
264
 
260
265
  const html = `<table cellpadding="0" cellspacing="0" border="0" style="${tableStyle}">
@@ -265,116 +270,12 @@ export function ctaBox(opts: CtaBoxOptions): Block {
265
270
 
266
271
  const text = href ? `${asciiCtaBox(label)}\n\n${href}` : asciiCtaBox(label);
267
272
 
268
- return { html, text, spacing: "normal" };
269
- }
270
-
271
- // =============================================================================
272
- // Chat bubbles — one dual-output block per role
273
- // =============================================================================
274
-
275
- /** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
276
- function wrapText(text: string, width: number): string[] {
277
- const lines: string[] = [];
278
- let cur = "";
279
- for (const word of text.split(/\s+/).filter(Boolean)) {
280
- let w = word;
281
- while (w.length > width) {
282
- if (cur) {
283
- lines.push(cur);
284
- cur = "";
285
- }
286
- lines.push(w.slice(0, width));
287
- w = w.slice(width);
288
- }
289
- if (!cur) cur = w;
290
- else if (cur.length + 1 + w.length <= width) cur += ` ${w}`;
291
- else {
292
- lines.push(cur);
293
- cur = w;
294
- }
295
- }
296
- if (cur) lines.push(cur);
297
- return lines.length ? lines : [""];
273
+ return { html, text };
298
274
  }
299
275
 
300
- /** Chat message truncation budget: at most MAX_MESSAGE_LINES lines of MESSAGE_WIDTH chars. */
301
- const MAX_MESSAGE_LINES = 3;
302
- const MESSAGE_WIDTH = 35;
303
-
304
- /** Word-wrap `text`, then clamp to `maxLines`, ellipsizing the last line on overflow. */
305
- function wrapClamped(text: string, width: number, maxLines: number): string[] {
306
- const lines = wrapText(text, width);
307
- if (lines.length <= maxLines) return lines;
308
- const kept = lines.slice(0, maxLines);
309
- const last = kept[maxLines - 1];
310
- kept[maxLines - 1] =
311
- (last.length > width - 3 ? last.slice(0, width - 3).trimEnd() : last) +
312
- "...";
313
- return kept;
314
- }
315
-
316
- /**
317
- * The one truncation authority: clamp a raw message to MAX_MESSAGE_LINES ×
318
- * MESSAGE_WIDTH, ellipsized. Both surfaces of a chat block run content through
319
- * this, so HTML and plain text truncate at exactly the same point.
320
- */
321
- function clampMessage(text: string): string {
322
- return wrapClamped(text, MESSAGE_WIDTH, MAX_MESSAGE_LINES).join(" ");
323
- }
324
-
325
- /**
326
- * Right-aligned user chat message. Truncated by clampMessage. In plain text the
327
- * face sits beside the last message line. When `from` is given (the sender's
328
- * name) it renders as an attribution below, right-aligned to the message's right
329
- * edge.
330
- */
331
- export function chatUser(content: string, from?: string): Block {
332
- const clamped = clampMessage(content);
333
-
334
- const attributionRow = from
335
- ? `
336
- <tr>
337
- <td style="${MONO} font-size: ${FONT_SIZE}; color: #888; text-align: right; padding-top: 4px;">${escapeHtml(from)}</td>
338
- <td></td>
339
- </tr>`
340
- : "";
341
- const html = `<table cellpadding="0" cellspacing="0" border="0" style="margin: 0 0 16px 0;">
342
- <tr>
343
- <td style="border: 1px solid #1a1a1a; border-radius: 12px 12px 0 12px; padding: 10px 14px; min-width: 280px; text-align: right; ${MONO} font-size: ${FONT_SIZE}; background: #f5f5f5;">${escapeHtml(clamped)}</td>
344
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom;">(•̀_ರ╮)</td>
345
- </tr>${attributionRow}
346
- </table>`;
347
-
348
- const border = "─".repeat(MESSAGE_WIDTH + 1);
349
- const lines = wrapText(clamped, MESSAGE_WIDTH);
350
- const body = lines.map((line) => `│${line.padStart(MESSAGE_WIDTH)} │`);
351
- // Face beside the last message line (one row up from the bottom border).
352
- body[body.length - 1] += " (•̀_ರ╮)";
353
- const box = [`┌${border}┐`, ...body, `└${border}┘`];
354
- if (from) box.push(from.padStart(MESSAGE_WIDTH + 3));
355
-
356
- return { html, text: box.join("\n"), spacing: "normal" };
357
- }
358
-
359
- /** Left-aligned assistant chat message with `[c_S]` avatar; in plain text the
360
- * avatar sits beside the last message line (one row up from the bottom border). */
361
- export function chatAssistant(content: string): Block {
362
- const clamped = clampMessage(content);
363
-
364
- const html = `<table cellpadding="0" cellspacing="0" border="0" style="margin: 16px 0;">
365
- <tr>
366
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom;">[c_S]</td>
367
- <td style="border: 1px solid #1a1a1a; border-radius: 12px 12px 12px 0; padding: 10px 14px; min-width: 280px; ${MONO} font-size: ${FONT_SIZE}; background: #f5f5f5;">${escapeHtml(clamped)}</td>
368
- </tr>
369
- </table>`;
370
-
371
- const lineWidth = 36;
372
- const lines = wrapText(clamped, lineWidth);
373
- const top = " ┌──────────────────────────────────────┐";
374
- const bot = " └──────────────────────────────────────┘";
375
- const boxLines = lines.map((line) => ` │ ${line.padEnd(lineWidth)} │`);
376
- // Avatar beside the last message line (one row up from the bottom border).
377
- boxLines[boxLines.length - 1] = `[c_S] ${boxLines[boxLines.length - 1].slice(7)}`;
378
-
379
- return { html, text: [top, ...boxLines, bot].join("\n"), spacing: "normal" };
276
+ /** The bordered `>> LABEL <<` CTA box (OTP / JOIN / OPEN / VIEW TEAM / …). When
277
+ * `href` is given, HTML links the label and plain text prints the URL below. */
278
+ export function ctaBox(opts: CtaBoxOptions): Block {
279
+ const { html, text } = ctaButton(opts, "0 0 20px 0");
280
+ return { html, text, spacing: "normal" };
380
281
  }
@@ -6,36 +6,30 @@ import type { EmailPayloads } from "../types";
6
6
 
7
7
  import {
8
8
  type Block,
9
- chatAssistant,
10
- chatDots,
11
- chatUser,
12
- ctaBox,
13
9
  footer,
10
+ greeting,
14
11
  NOTICE,
15
12
  paragraph,
16
13
  signature,
17
14
  } from "./blocks";
15
+ import { chatAssistant, chatCta, chatDots, chatUnit, chatUser } from "./chat";
18
16
  import { COMPANY_NAME } from "./constants";
19
17
 
20
18
  export type ChatSharedPayload = EmailPayloads["chat.shared"];
21
19
 
22
20
  export function renderChatShared(payload: ChatSharedPayload): Block[] {
23
- const { chatTitle, shareUrl, previewText } = payload;
24
-
25
- const blocks: Block[] = [
26
- paragraph("A chat has been shared with you."),
27
- chatUser(chatTitle),
28
- ];
29
-
30
- if (previewText) blocks.push(chatAssistant(previewText));
31
-
32
- blocks.push(
33
- chatDots(),
34
- ctaBox({ label: "SEE MORE", href: shareUrl }),
35
- footer(`This share was sent via ${COMPANY_NAME}.`),
36
- paragraph(NOTICE, "tight"),
21
+ const { chatTitle, shareUrl, previewText, sharedByName } = payload;
22
+
23
+ return [
24
+ greeting(),
25
+ paragraph("A chat was shared with you."),
26
+ chatUnit(
27
+ chatUser(chatTitle, sharedByName),
28
+ ...(previewText ? [chatAssistant(previewText)] : []),
29
+ chatDots(),
30
+ chatCta({ label: "SEE MORE", href: shareUrl }),
31
+ ),
32
+ footer(`This share was sent via ${COMPANY_NAME}.`, NOTICE, "none"),
37
33
  signature(),
38
- );
39
-
40
- return blocks;
34
+ ];
41
35
  }