@company-semantics/contracts 33.0.0 → 35.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.
Files changed (78) hide show
  1. package/package.json +4 -4
  2. package/src/api/generated-spec-hash.ts +2 -2
  3. package/src/api/generated.ts +2 -2
  4. package/src/index.ts +17 -11
  5. package/src/notifications/README.md +140 -0
  6. package/src/notifications/__tests__/README.md +54 -0
  7. package/src/notifications/__tests__/__snapshots__/README.md +32 -0
  8. package/src/notifications/__tests__/content.test.ts +186 -0
  9. package/src/notifications/__tests__/context.test.ts +72 -0
  10. package/src/notifications/__tests__/definition.test.ts +222 -0
  11. package/src/notifications/__tests__/kinds.test.ts +81 -0
  12. package/src/notifications/__tests__/output-parity.golden.ts +363 -0
  13. package/src/notifications/__tests__/output-parity.test.ts +122 -0
  14. package/src/notifications/__tests__/registry.test.ts +184 -0
  15. package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +58 -29
  16. package/src/notifications/__tests__/renderer.test.ts +181 -0
  17. package/src/notifications/content.ts +249 -0
  18. package/src/notifications/context.ts +70 -0
  19. package/src/notifications/definition.ts +82 -0
  20. package/src/notifications/index.ts +104 -0
  21. package/src/notifications/kinds/README.md +57 -0
  22. package/src/notifications/kinds/auth-otp.ts +99 -0
  23. package/src/notifications/kinds/chat-shared.ts +56 -0
  24. package/src/notifications/kinds/company-md-access-approved.ts +57 -0
  25. package/src/notifications/kinds/company-md-access-denied.ts +61 -0
  26. package/src/notifications/kinds/company-md-access-requested.ts +65 -0
  27. package/src/notifications/kinds/index.ts +19 -0
  28. package/src/notifications/kinds/org-invite.ts +62 -0
  29. package/src/notifications/kinds/ownership-transfer-completed.ts +57 -0
  30. package/src/notifications/kinds/ownership-transfer.ts +68 -0
  31. package/src/notifications/kinds/security-alert.ts +78 -0
  32. package/src/notifications/kinds/share-granted.ts +74 -0
  33. package/src/notifications/kinds/unit-owner-granted.ts +95 -0
  34. package/src/notifications/kinds.ts +68 -0
  35. package/src/{email/types.ts → notifications/payloads.ts} +33 -68
  36. package/src/notifications/registry.ts +107 -0
  37. package/src/notifications/render.ts +106 -0
  38. package/src/notifications/renderer.ts +50 -0
  39. package/src/notifications/renderers/README.md +50 -0
  40. package/src/notifications/renderers/email/README.md +74 -0
  41. package/src/notifications/renderers/email/__tests__/README.md +29 -0
  42. package/src/notifications/renderers/email/__tests__/render.test.ts +180 -0
  43. package/src/{email/render → notifications/renderers/email}/chat.ts +53 -107
  44. package/src/notifications/renderers/email/constants.ts +47 -0
  45. package/src/notifications/renderers/email/cta.ts +63 -0
  46. package/src/{email/render → notifications/renderers/email}/escape-html.ts +5 -1
  47. package/src/notifications/renderers/email/index.ts +73 -0
  48. package/src/notifications/renderers/email/render.ts +225 -0
  49. package/src/notifications/renderers/email/shells.ts +60 -0
  50. package/src/notifications/renderers/slack/README.md +73 -0
  51. package/src/notifications/renderers/slack/__tests__/README.md +33 -0
  52. package/src/notifications/renderers/slack/__tests__/index.test.ts +201 -0
  53. package/src/notifications/renderers/slack/index.ts +261 -0
  54. package/src/notifications/renderers/sms/README.md +69 -0
  55. package/src/notifications/renderers/sms/__tests__/README.md +31 -0
  56. package/src/notifications/renderers/sms/__tests__/index.test.ts +162 -0
  57. package/src/notifications/renderers/sms/index.ts +131 -0
  58. package/src/notifications/text.ts +52 -0
  59. package/src/email/README.md +0 -51
  60. package/src/email/__tests__/registry.test.ts +0 -161
  61. package/src/email/index.ts +0 -36
  62. package/src/email/registry.ts +0 -155
  63. package/src/email/render/auth-otp.ts +0 -71
  64. package/src/email/render/blocks.ts +0 -281
  65. package/src/email/render/chat-shared.ts +0 -35
  66. package/src/email/render/company-md-access-approved.ts +0 -42
  67. package/src/email/render/company-md-access-denied.ts +0 -43
  68. package/src/email/render/company-md-access-requested.ts +0 -46
  69. package/src/email/render/constants.ts +0 -18
  70. package/src/email/render/index.ts +0 -58
  71. package/src/email/render/org-invite.ts +0 -40
  72. package/src/email/render/ownership-transfer-completed.ts +0 -41
  73. package/src/email/render/ownership-transfer.ts +0 -42
  74. package/src/email/render/render-email.ts +0 -194
  75. package/src/email/render/security-alert.ts +0 -61
  76. package/src/email/render/share-granted.ts +0 -52
  77. package/src/email/render/unit-owner-granted.ts +0 -60
  78. /package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +0 -0
@@ -0,0 +1,74 @@
1
+ # notifications/renderers/email/
2
+
3
+ ## Purpose
4
+
5
+ The email channel (ADR-CONTRACTS-086) — `emailRenderer: Renderer<RenderedEmail>`.
6
+
7
+ This is the old `src/email/render` turned inside out — and, now that it is
8
+ deleted, what replaced it. That layer offered templates a component per intent
9
+ (`greeting()`, `keyValue()`, `footer()`) and each template chose which to call;
10
+ here the CONTENT names the intent and this directory is the only thing that knows
11
+ the markup. The strings are the same strings.
12
+
13
+ | module | what it holds |
14
+ | ---------------- | ----------------------------------------------------------------------- |
15
+ | `index.ts` | `emailRenderer` + `RenderedEmail`, the channel's natural type |
16
+ | `render.ts` | each `NotificationElement` → its `<p>`/table markup, and spacing |
17
+ | `chat.ts` | `chatUnit` → HTML bubbles and plain-text box art |
18
+ | `cta.ts` | the `>> LABEL <<` button, shared by a standalone CTA and a chat one |
19
+ | `shells.ts` | `htmlShell` / `textShell` — what makes one email dual-output |
20
+ | `constants.ts` | the styling vocabulary (`MONO`, `FONT_SIZE`, `SPACING`) + `COMPANY_URL` |
21
+ | `escape-html.ts` | `escapeHtml` |
22
+
23
+ `constants.ts` and `escape-html.ts` moved here from the old `src/email/render`
24
+ before it was deleted. The brand NAME is deliberately not among them: it is
25
+ `../../context`'s `COMPANY_NAME`, because it is the same answer on every channel
26
+ and renderers read it from `context.brand`.
27
+
28
+ ## Spacing — the thing the content model does not carry
29
+
30
+ The old layer passed a `Spacing` per block; `../../kinds` deliberately dropped
31
+ it, because a margin is a channel's answer. So this channel re-derives one, with
32
+ a single rule:
33
+
34
+ > An element renders as one or more lines. Lines WITHIN an element hug
35
+ > (`"tight"`); the element's LAST line carries the trailing gap, which is
36
+ > `"normal"` — or `"none"` when a `signature` follows, because `signature` owns
37
+ > the blank above its own rule.
38
+
39
+ That reproduces every hand-authored spacing choice in all eleven old templates.
40
+ The one place it did not fall out was `security.alert`'s reassurance/denial
41
+ couplet, and the fix was to say so in the CONTENT — one `body` with a newline —
42
+ rather than to special-case a kind here. See `../../kinds/security-alert.ts`.
43
+
44
+ `list`, `divider` and `heroImage` have no counterpart in the old layer and no
45
+ kind composes one, so their markup is invented rather than relocated. `supports`
46
+ still answers true — email can depict them — but the first kind to use one should
47
+ expect to style it properly rather than trust the placeholder.
48
+
49
+ ## Invariants
50
+
51
+ - Output is byte-identical to what the old `src/email/render` emitted. This is
52
+ the whole constraint of the migration. `__tests__/render.test.ts` proves it by
53
+ composing every fixture in the frozen output-parity golden and rendering it
54
+ through this channel — a diff there means a real sent email changed. The golden
55
+ is the only record of those bytes now, so it is the authority: fix the
56
+ renderer, never the golden.
57
+ - PURE. `render` is a function of `(content, context)`: no clock, no environment,
58
+ no I/O. The copyright year comes from `context.brand.copyrightYear`, which is
59
+ what retires the `new Date().getFullYear()` hidden inside the old
60
+ `signature()`.
61
+ - `supports` answers true for EVERY element type. Email is the rich channel;
62
+ declining an element would be a lie about the channel rather than a fact about
63
+ the notification.
64
+ - The `:` between a label and its value is presentation and lives in no element
65
+ (see `../../kinds/README.md`). `render.ts` puts it back — on key/value rows and
66
+ on the metadata heading.
67
+ - Values arrive pre-formatted: dates, title-casing and truncation are `compose`'s
68
+ work. Nothing here re-formats a value.
69
+ - Every user-controlled field in the HTML surface passes through `escapeHtml`
70
+ before interpolation.
71
+ - `MONO` / `FONT_SIZE` / `SPACING` are this channel's own and are NOT exported
72
+ from the domain barrel: a margin is not vocabulary. They were duplicated from
73
+ the old `email/render/blocks`'s private copies while both layers existed; that
74
+ duplication ended with the directory, and these are now the only copies.
@@ -0,0 +1,29 @@
1
+ # notifications/renderers/email/\_\_tests\_\_/
2
+
3
+ ## Purpose
4
+
5
+ Tests for the email channel (ADR-CONTRACTS-086).
6
+
7
+ `render.test.ts` holds the renderer's one load-bearing proof: every fixture in
8
+ `../../../__tests__/output-parity.golden.ts`, composed through
9
+ `NOTIFICATION_DEFINITIONS` and rendered through `emailRenderer`, byte-for-byte.
10
+ The rest of the file pins the properties the golden cannot see — that `supports`
11
+ is total, that the year comes from `RenderContext`, that the `:` separator the
12
+ content model drops is put back, and that user-controlled fields are escaped.
13
+
14
+ ## Invariants
15
+
16
+ - The golden is the ORACLE, not this renderer's output. When the two disagree,
17
+ the renderer is wrong: the golden records what the old `src/email/render`
18
+ emitted at 32.0.0, and "no rendered email changed" is the migration's whole
19
+ promise. It is never regenerated to make this file pass — and since that layer
20
+ is now deleted, the golden is the only record of those bytes, so regenerating
21
+ it destroys the evidence rather than updating it.
22
+ - NO fake timers. The old layer's `signature()` read the wall clock, which is why
23
+ every render test had to freeze it; here the year is a field on
24
+ `RenderContext`, and keeping this file clock-free is the improvement being
25
+ locked in.
26
+ - Parity is asserted at two levels against one golden: here through the RENDERER
27
+ (composed content → bytes), and in `../../../__tests__/output-parity.test.ts`
28
+ through the ENTRY POINT (`renderToChannel`, the whole pipeline). Either one
29
+ drifting from the golden is a failure.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The email renderer's correctness proof (ADR-CONTRACTS-086).
3
+ *
4
+ * The renderer has exactly one hard requirement: compose a kind, render it
5
+ * through this channel, and get the bytes the old `src/email/render` produced.
6
+ * So that is what this asserts — every fixture in the frozen output-parity
7
+ * golden, composed via `NOTIFICATION_DEFINITIONS` and rendered through
8
+ * `emailRenderer`, character-for-character.
9
+ *
10
+ * This does NOT replace `../../../__tests__/output-parity.test.ts`. That one
11
+ * pins the ENTRY POINT (`renderToChannel`, the whole pipeline) against the same
12
+ * golden; this pins the RENDERER alone, so a failure here says the markup moved
13
+ * rather than that the wiring did. Two levels, one golden: either drifting is a
14
+ * failure.
15
+ *
16
+ * There is no fake timer here, and that is the improvement being locked in: the
17
+ * year comes from `RenderContext`, so the golden's clock is just a number.
18
+ */
19
+
20
+ import { describe, expect, it } from "vitest";
21
+
22
+ import type {
23
+ NotificationContent,
24
+ NotificationElementType,
25
+ } from "../../../content";
26
+ import type { RenderContext } from "../../../context";
27
+ import { NOTIFICATION_DEFINITIONS } from "../../../registry";
28
+ import {
29
+ OUTPUT_PARITY_CLOCK,
30
+ OUTPUT_PARITY_FIXTURES,
31
+ OUTPUT_PARITY_GOLDEN,
32
+ fixtureKey,
33
+ } from "../../../__tests__/output-parity.golden";
34
+ import { emailRenderer } from "../index";
35
+
36
+ /**
37
+ * The golden's context, stated rather than ticked. The old layer reached for
38
+ * `new Date().getFullYear()` mid-render, which is why every render test had to
39
+ * freeze the clock; here the year the golden was captured under is simply a
40
+ * value.
41
+ */
42
+ const GOLDEN_CONTEXT: RenderContext = {
43
+ brand: {
44
+ name: "Company Semantics",
45
+ copyrightYear: new Date(OUTPUT_PARITY_CLOCK).getUTCFullYear(),
46
+ },
47
+ };
48
+
49
+ const ALL_TYPES: NotificationElementType[] = [
50
+ "greeting",
51
+ "body",
52
+ "keyValueTable",
53
+ "callToAction",
54
+ "list",
55
+ "divider",
56
+ "warning",
57
+ "metadata",
58
+ "notice",
59
+ "chatUnit",
60
+ "signature",
61
+ "heroImage",
62
+ ];
63
+
64
+ describe("emailRenderer", () => {
65
+ it("declares the email channel", () => {
66
+ expect(emailRenderer.id).toBe("email");
67
+ });
68
+
69
+ it("supports every element type — email is the rich channel", () => {
70
+ for (const type of ALL_TYPES) {
71
+ expect(emailRenderer.supports(type), type).toBe(true);
72
+ }
73
+ });
74
+
75
+ it("renders the subject from the content's title, not a registry", () => {
76
+ const content: NotificationContent = {
77
+ metadata: { kind: "org.invite", title: "Join Acme" },
78
+ sections: [{ elements: [{ type: "body", text: "Hello." }] }],
79
+ };
80
+ expect(emailRenderer.render(content, GOLDEN_CONTEXT).subject).toBe(
81
+ "Join Acme",
82
+ );
83
+ });
84
+
85
+ it("takes the copyright year and signer from context, never from a clock", () => {
86
+ const content: NotificationContent = {
87
+ metadata: { kind: "org.invite", title: "t" },
88
+ sections: [{ elements: [{ type: "signature" }] }],
89
+ };
90
+ const pinned: RenderContext = {
91
+ brand: { name: "Acme", copyrightYear: 1999 },
92
+ };
93
+ expect(emailRenderer.render(content, pinned).text).toContain(
94
+ "ⓒ 1999 • Acme",
95
+ );
96
+ });
97
+
98
+ it("lets an element's signer override the context brand", () => {
99
+ const content: NotificationContent = {
100
+ metadata: { kind: "org.invite", title: "t" },
101
+ sections: [{ elements: [{ type: "signature", signer: "Grace" }] }],
102
+ };
103
+ expect(emailRenderer.render(content, GOLDEN_CONTEXT).text).toContain(
104
+ "• Grace",
105
+ );
106
+ });
107
+
108
+ it("puts back the ':' the content model does not carry", () => {
109
+ // The separator is presentation and lives in no element — a renderer that
110
+ // forgets it silently drops the colon from every fact in every email.
111
+ const content: NotificationContent = {
112
+ metadata: { kind: "auth.otp", title: "t" },
113
+ sections: [
114
+ {
115
+ elements: [
116
+ {
117
+ type: "keyValueTable",
118
+ rows: [{ label: "Status", value: "Valid" }],
119
+ },
120
+ {
121
+ type: "metadata",
122
+ label: "Request details",
123
+ rows: [{ label: "IP address", value: "203.0.113.1" }],
124
+ },
125
+ ],
126
+ },
127
+ ],
128
+ };
129
+ const { text } = emailRenderer.render(content, GOLDEN_CONTEXT);
130
+ expect(text).toContain("Status: Valid");
131
+ expect(text).toContain("Request details:");
132
+ expect(text).toContain("IP address: 203.0.113.1");
133
+ });
134
+
135
+ it("escapes user-controlled content in the HTML surface", () => {
136
+ const content: NotificationContent = {
137
+ metadata: { kind: "org.invite", title: "t" },
138
+ sections: [
139
+ {
140
+ elements: [{ type: "greeting", recipientName: "<script>x</script>" }],
141
+ },
142
+ ],
143
+ };
144
+ const { html } = emailRenderer.render(content, GOLDEN_CONTEXT);
145
+ expect(html).toContain("&lt;script&gt;");
146
+ expect(html).not.toContain("<script>");
147
+ });
148
+
149
+ it("is pure — same inputs, same bytes", () => {
150
+ const content = NOTIFICATION_DEFINITIONS["security.alert"].compose(
151
+ {
152
+ alertType: "excessive_otp_requests",
153
+ details: "5 codes in 2 minutes",
154
+ timestamp: "2026-07-15T00:00:00.000Z",
155
+ },
156
+ GOLDEN_CONTEXT,
157
+ );
158
+ expect(emailRenderer.render(content, GOLDEN_CONTEXT)).toEqual(
159
+ emailRenderer.render(content, GOLDEN_CONTEXT),
160
+ );
161
+ });
162
+
163
+ describe("output parity with the email layer at 32.0.0", () => {
164
+ for (const fixture of OUTPUT_PARITY_FIXTURES) {
165
+ const key = fixtureKey(fixture.kind, fixture.name);
166
+ it(`${key} renders byte-for-byte`, () => {
167
+ const definition = NOTIFICATION_DEFINITIONS[fixture.kind];
168
+ // `payload as never`: the fixture call sites are type-checked in the
169
+ // golden module; this only bridges compose's per-kind payload.
170
+ const content = definition.compose(
171
+ fixture.payload as never,
172
+ GOLDEN_CONTEXT,
173
+ );
174
+ expect(emailRenderer.render(content, GOLDEN_CONTEXT)).toEqual(
175
+ OUTPUT_PARITY_GOLDEN[key],
176
+ );
177
+ });
178
+ }
179
+ });
180
+ });
@@ -1,32 +1,24 @@
1
1
  /**
2
- * Chat unitthe dual-output chat transcript block.
2
+ * Email's answer to a `chatUnit` bubbles in HTML, box art in plain text.
3
3
  *
4
- * The one component with real layout of its own: message bubbles, an in-stream
5
- * CTA, and continuation dots, laid out as a bracketed "unit". Same contract as
6
- * every other component in `blocks.ts` (returns a `Block` = `{ html, text }`, so
7
- * both surfaces derive from one source); it lives apart because bubble geometry
8
- * wrapping, truncation, avatar gutters, alignment — is a self-contained
9
- * concern that the paragraph/CTA primitives next door do not share.
4
+ * Moved verbatim from the deleted `src/email/render/blocks` (ADR-CONTRACTS-086), with
5
+ * one change: it lays out `ChatUnitItem`s from `../../content` rather than the
6
+ * old layer's parallel `ChatItem` vocabulary. The mapping is exact
7
+ * `message` a bubble, `callToAction` an embedded button, `continuation`
8
+ * the "⋮" dots.
10
9
  *
11
10
  * INVARIANTS:
12
- * - Pure functions, no side effects.
13
- * - Components escape their own content; templates pass raw text.
14
- * - HTML and plain text truncate at exactly the same point (`clampMessage` is
15
- * the single truncation authority).
11
+ * - Pure, and byte-identical to what the deleted `src/email/render/blocks` emits. The
12
+ * box art is real output: the output-parity golden asserts every column of it.
13
+ * - Both surfaces truncate at the same point `clampMessage` is the one
14
+ * truncation authority, and HTML and plain text both run content through it.
16
15
  */
17
16
 
18
- import {
19
- type Block,
20
- type CtaBoxOptions,
21
- ctaButton,
22
- FONT_SIZE,
23
- MONO,
24
- } from "./blocks";
25
- import { escapeHtml } from "./escape-html";
17
+ import type { CallToAction, ChatTurn, ChatUnitItem } from "../../content";
26
18
 
27
- // =============================================================================
28
- // Text geometry
29
- // =============================================================================
19
+ import { FONT_SIZE, MONO } from "./constants";
20
+ import { ctaButton } from "./cta";
21
+ import { escapeHtml } from "./escape-html";
30
22
 
31
23
  /** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
32
24
  function wrapText(text: string, width: number): string[] {
@@ -74,65 +66,13 @@ function wrapClamped(text: string, width: number, maxLines: number): string[] {
74
66
 
75
67
  /**
76
68
  * The one truncation authority: clamp a raw message to MAX_MESSAGE_LINES ×
77
- * MESSAGE_WIDTH, ellipsized. Both surfaces of a chat block run content through
69
+ * MESSAGE_WIDTH, ellipsized. Both surfaces of a chat unit run content through
78
70
  * this, so HTML and plain text truncate at exactly the same point.
79
71
  */
80
72
  function clampMessage(text: string): string {
81
73
  return wrapClamped(text, MESSAGE_WIDTH, MAX_MESSAGE_LINES).join(" ");
82
74
  }
83
75
 
84
- // =============================================================================
85
- // Chat items
86
- // =============================================================================
87
-
88
- /** One message in a chat unit. `chatUser`/`chatAssistant` build these; `chatUnit`
89
- * lays them out together. `from` is the user attribution (sender name). */
90
- export interface ChatMessage {
91
- role: "user" | "assistant";
92
- text: string;
93
- from?: string;
94
- }
95
-
96
- /** A user (right-aligned) chat message with an optional `from` attribution. */
97
- export function chatUser(text: string, from?: string): ChatMessage {
98
- return { role: "user", text, from };
99
- }
100
-
101
- /** An assistant (left-aligned) chat message. */
102
- export function chatAssistant(text: string): ChatMessage {
103
- return { role: "assistant", text };
104
- }
105
-
106
- /** A CTA button placed inside a chat unit (below a message). */
107
- export interface ChatCta {
108
- role: "cta";
109
- cta: CtaBoxOptions;
110
- }
111
-
112
- /** Centered continuation dots inside a chat unit — a "conversation continues"
113
- * separator placed between a message bubble and a following CTA. */
114
- export interface ChatDots {
115
- role: "dots";
116
- }
117
-
118
- /** An item in a chat unit: a message bubble, a CTA button, or continuation dots. */
119
- export type ChatItem = ChatMessage | ChatCta | ChatDots;
120
-
121
- /** A CTA button for a chat unit — pass it to `chatUnit` alongside messages. */
122
- export function chatCta(cta: CtaBoxOptions): ChatCta {
123
- return { role: "cta", cta };
124
- }
125
-
126
- /** Continuation dots for a chat unit — pass it to `chatUnit` where the
127
- * conversation should read as continuing (e.g. between the preview and CTA). */
128
- export function chatDots(): ChatDots {
129
- return { role: "dots" };
130
- }
131
-
132
- // =============================================================================
133
- // Item rendering
134
- // =============================================================================
135
-
136
76
  /** The `<hr>` bracketing a chat unit — 24px toward the bubbles, 12px on the
137
77
  * outer side. */
138
78
  function chatRuleHtml(position: "top" | "bottom"): string {
@@ -141,18 +81,18 @@ function chatRuleHtml(position: "top" | "bottom"): string {
141
81
  }
142
82
 
143
83
  /**
144
- * Render one message to its HTML `<table>` and plain-text box lines. Both roles
84
+ * Render one turn to its HTML `<table>` and plain-text box lines. Both roles
145
85
  * share the 3-column skeleton: a fixed avatar column on each side (the visible
146
86
  * avatar plus the opposite avatar rendered `visibility: hidden` to reserve its
147
87
  * width, so bubbles stay bounded and aligned), a middle cell that right/left-
148
88
  * aligns the bubble, and — for a user `from` — an attribution row below.
149
89
  */
150
90
  function renderBubble(
151
- msg: ChatMessage,
91
+ turn: ChatTurn,
152
92
  margin: string,
153
93
  ): { html: string; text: string[] } {
154
- const clamped = clampMessage(msg.text);
155
- const isUser = msg.role === "user";
94
+ const clamped = clampMessage(turn.text);
95
+ const isUser = turn.role === "user";
156
96
 
157
97
  const radius = isUser ? "8px 8px 0 8px" : "8px 8px 8px 0";
158
98
  const bubbleAlign = isUser ? " text-align: right;" : "";
@@ -161,11 +101,11 @@ function renderBubble(
161
101
  const kaomojiHidden = isUser ? "" : "visibility: hidden; ";
162
102
 
163
103
  const attributionRow =
164
- isUser && msg.from
104
+ isUser && turn.from
165
105
  ? `
166
106
  <tr>
167
107
  <td></td>
168
- <td style="${MONO} font-size: ${FONT_SIZE}; color: #666; text-align: right; padding-top: 6px; padding-right: 1ch;">${escapeHtml(msg.from)}</td>
108
+ <td style="${MONO} font-size: ${FONT_SIZE}; color: #666; text-align: right; padding-top: 6px; padding-right: 1ch;">${escapeHtml(turn.from)}</td>
169
109
  <td></td>
170
110
  </tr>`
171
111
  : "";
@@ -196,8 +136,8 @@ function renderBubble(
196
136
  ...body,
197
137
  `${CHAT_INDENT}└${border}┘`,
198
138
  ];
199
- if (isUser && msg.from) {
200
- box.push(msg.from.padStart(CHAT_INDENT.length + MESSAGE_WIDTH + 3));
139
+ if (isUser && turn.from) {
140
+ box.push(turn.from.padStart(CHAT_INDENT.length + MESSAGE_WIDTH + 3));
201
141
  }
202
142
  return { html, text: box };
203
143
  }
@@ -219,7 +159,7 @@ function dotsOverCtaHtml(): string {
219
159
  * When `withDots`, continuation "⋮" render just above the button, centered over it.
220
160
  */
221
161
  function renderChatCta(
222
- cta: CtaBoxOptions,
162
+ cta: CallToAction,
223
163
  align: "left" | "right",
224
164
  withDots: boolean,
225
165
  ): { html: string; text: string[] } {
@@ -259,8 +199,8 @@ function renderChatCta(
259
199
 
260
200
  /**
261
201
  * Standalone continuation dots — centered in the message channel. Used only when
262
- * `chatDots()` is NOT immediately followed by a CTA (the common case folds the
263
- * dots into the CTA via `renderChatCta`, centered over the box).
202
+ * a `continuation` is NOT immediately followed by a `callToAction` (the common
203
+ * case folds the dots into the CTA via `renderChatCta`, centered over the box).
264
204
  */
265
205
  function renderChatDots(): { html: string; text: string[] } {
266
206
  const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 16px 0;">
@@ -275,57 +215,63 @@ function renderChatDots(): { html: string; text: string[] } {
275
215
  return { html, text: ["⋮".padStart(center)] };
276
216
  }
277
217
 
278
- // =============================================================================
279
- // Chat unit
280
- // =============================================================================
281
-
282
218
  /**
283
- * Lay out one or more chat items — message bubbles and/or CTA buttons — as a
284
- * single block: a rule above and below (the real-chat "unit"), the items
219
+ * Lay out a chat unit's items — bubbles and/or CTA buttons — as a single
220
+ * rendered thing: a rule above and below (the real-chat "unit"), the items
285
221
  * between. The plain-text rule spans the widest line; a blank line follows the
286
- * top rule and the last item hugs the bottom rule. Continuation dots
287
- * (`chatDots`) sit above the CTA they precede, centered over the box.
222
+ * top rule and the last item hugs the bottom rule. Continuation dots sit above
223
+ * the CTA they precede, centered over the box.
288
224
  */
289
- export function chatUnit(...items: ChatItem[]): Block {
225
+ export function renderChatUnit(items: ChatUnitItem[]): {
226
+ html: string;
227
+ text: string;
228
+ } {
290
229
  const parts: { html: string; text: string[] }[] = [];
291
230
  items.forEach((item, i) => {
292
- if (item.role === "dots") {
231
+ if (item.type === "continuation") {
293
232
  // Dots immediately before a CTA render with it (centered over the box);
294
233
  // otherwise fall back to channel-centered standalone dots.
295
- if (items[i + 1]?.role !== "cta") parts.push(renderChatDots());
234
+ if (items[i + 1]?.type !== "callToAction") parts.push(renderChatDots());
296
235
  return;
297
236
  }
298
- if (item.role === "cta") {
237
+ if (item.type === "callToAction") {
299
238
  // Mirror the side of the nearest preceding message (skip any dots between),
300
239
  // so the CTA sits under the bubble it belongs to — right under a user.
301
240
  let j = i - 1;
302
- while (j >= 0 && items[j].role === "dots") j--;
303
- const align = items[j]?.role === "user" ? "right" : "left";
304
- parts.push(renderChatCta(item.cta, align, items[i - 1]?.role === "dots"));
241
+ while (j >= 0 && items[j].type === "continuation") j--;
242
+ const prev = items[j];
243
+ const align =
244
+ prev?.type === "message" && prev.role === "user" ? "right" : "left";
245
+ parts.push(
246
+ renderChatCta(item, align, items[i - 1]?.type === "continuation"),
247
+ );
305
248
  return;
306
249
  }
307
250
  // A bubble directly above a CTA or continuation dots gets a tighter 16px
308
251
  // gap; else 24px.
309
- const next = items[i + 1]?.role;
252
+ const next = items[i + 1]?.type;
310
253
  const margin =
311
- next === "cta" || next === "dots" ? "0 0 16px 0" : "0 0 24px 0";
254
+ next === "callToAction" || next === "continuation"
255
+ ? "0 0 16px 0"
256
+ : "0 0 24px 0";
312
257
  parts.push(renderBubble(item, margin));
313
258
  });
259
+
314
260
  const width = parts
315
- .flatMap((b) => b.text)
261
+ .flatMap((part) => part.text)
316
262
  .reduce((w, l) => Math.max(w, l.length), 0);
317
263
  const rule = "_".repeat(width);
318
264
 
319
265
  const html = [
320
266
  chatRuleHtml("top"),
321
- ...parts.map((b) => b.html),
267
+ ...parts.map((part) => part.html),
322
268
  chatRuleHtml("bottom"),
323
269
  ].join("\n");
324
270
 
325
271
  const text =
326
272
  `${rule}\n\n` +
327
- parts.map((b) => b.text.join("\n")).join("\n\n") +
273
+ parts.map((part) => part.text.join("\n")).join("\n\n") +
328
274
  `\n${rule}`;
329
275
 
330
- return { html, text, spacing: "normal" };
276
+ return { html, text };
331
277
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Email channel constants — the styling vocabulary and the one link in it
3
+ * (ADR-CONTRACTS-086). `EMAIL_FROM` (SES envelope) is infra and stays in the
4
+ * backend.
5
+ *
6
+ * These are the email channel's own answer and are deliberately NOT exported
7
+ * from the domain barrel: a margin is not vocabulary. The brand NAME is not here
8
+ * either — it is `../../context`'s `COMPANY_NAME`, because it is the same answer
9
+ * on every channel and renderers read it from `context.brand`.
10
+ */
11
+
12
+ // =============================================================================
13
+ // Branding
14
+ // =============================================================================
15
+
16
+ /** Monospace font stack for HTML emails. */
17
+ const MONO_FONT_STACK =
18
+ "'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace";
19
+
20
+ /** The `companysemantics.ai` link under every signature. */
21
+ export const COMPANY_URL = "https://companysemantics.ai";
22
+
23
+ // =============================================================================
24
+ // Styling
25
+ // =============================================================================
26
+
27
+ /** The `font-family` declaration every element carries. */
28
+ export const MONO = `font-family: ${MONO_FONT_STACK};`;
29
+
30
+ /**
31
+ * The single font size for every email element (HTML). Plain text is monospace
32
+ * so it carries no size — this keeps one visual size across both surfaces.
33
+ */
34
+ export const FONT_SIZE = "13px";
35
+
36
+ /**
37
+ * The gap AFTER a rendered line: `"normal"` is a blank line in plain text,
38
+ * anything else is none.
39
+ */
40
+ export type Spacing = "normal" | "tight" | "none";
41
+
42
+ /** The HTML margin each `Spacing` maps to. */
43
+ export const SPACING: Record<Spacing, string> = {
44
+ normal: "0 0 20px 0",
45
+ tight: "0 0 4px 0",
46
+ none: "0",
47
+ };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The `>> LABEL <<` button — email's answer to a `callToAction`.
3
+ *
4
+ * Moved verbatim from the deleted `src/email/render/blocks` (ADR-CONTRACTS-086). It
5
+ * lives in its own module because two callers need it: a standalone
6
+ * `callToAction` element (`./render`) and one inside a conversation
7
+ * (`./chat`), which place it at different margins.
8
+ *
9
+ * INVARIANTS:
10
+ * - Pure, and byte-identical to what the deleted `src/email/render/blocks` emits.
11
+ * - `href` absent means the label IS the payload (an OTP code): the button must
12
+ * not become a link, and plain text must not print a URL under it.
13
+ */
14
+
15
+ import type { CallToAction } from "../../content";
16
+
17
+ import { FONT_SIZE, MONO } from "./constants";
18
+ import { escapeHtml } from "./escape-html";
19
+
20
+ /** Padding columns on each side of the `>> LABEL <<` line in the ASCII box. */
21
+ const CTA_BOX_PAD = 3;
22
+
23
+ /** `>> LABEL <<` ASCII box (`*` corners): 3 lines, sized to the label. */
24
+ function asciiCtaBox(label: string): string {
25
+ const pad = " ".repeat(CTA_BOX_PAD);
26
+ const inner = `${pad}>> ${label} <<${pad}`;
27
+ const border = `*${"-".repeat(inner.length)}*`;
28
+ return [border, `|${inner}|`, border].join("\n");
29
+ }
30
+
31
+ /**
32
+ * The bordered `>> LABEL <<` button (HTML table + ascii text), with `margin` on
33
+ * the table. A standalone element wraps it as its own line; a chat unit embeds
34
+ * it.
35
+ */
36
+ export function ctaButton(
37
+ cta: CallToAction,
38
+ margin: string,
39
+ ): { html: string; text: string } {
40
+ const { label, href } = cta;
41
+
42
+ const tableStyle = `display: inline-block; border: 1px solid #666; border-radius: 2px; margin: ${margin}; max-width: 220px;`;
43
+ const tdStyle = `padding: 16px 24px; text-align: center; ${MONO} font-size: ${FONT_SIZE};`;
44
+
45
+ // Underline only the label text (not the chevrons/spaces), and only when linked.
46
+ const labelHtml = href
47
+ ? `<span style="text-decoration: underline;">${escapeHtml(label)}</span>`
48
+ : escapeHtml(label);
49
+ const chevrons = `&gt;&gt; ${labelHtml} &lt;&lt;`;
50
+ const inner = href
51
+ ? `<a href="${href}" style="color: #0047FF; text-decoration: none;">${chevrons}</a>`
52
+ : chevrons;
53
+
54
+ const html = `<table cellpadding="0" cellspacing="0" border="0" style="${tableStyle}">
55
+ <tr><td style="${tdStyle}">
56
+ ${inner}
57
+ </td></tr>
58
+ </table>`;
59
+
60
+ const text = href ? `${asciiCtaBox(label)}\n\n${href}` : asciiCtaBox(label);
61
+
62
+ return { html, text };
63
+ }
@@ -1,5 +1,9 @@
1
1
  /**
2
- * HTML escaping for email templates.
2
+ * HTML escaping for the email channel.
3
+ *
4
+ * Moved here from the deleted `src/email/render/escape-html`
5
+ * (ADR-CONTRACTS-086): escaping is one channel's concern, and the channel now
6
+ * owns it.
3
7
  *
4
8
  * INVARIANT: every user-controlled field in an HTML variant MUST pass through
5
9
  * this before interpolation (prevents XSS in email clients).