@company-semantics/contracts 34.0.0 → 35.1.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 (76) hide show
  1. package/package.json +4 -4
  2. package/src/index.ts +17 -11
  3. package/src/notifications/README.md +142 -0
  4. package/src/notifications/__tests__/README.md +49 -0
  5. package/src/notifications/__tests__/__snapshots__/README.md +39 -0
  6. package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +31 -0
  7. package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +2 -2
  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__/fixtures.ts +226 -0
  12. package/src/notifications/__tests__/kinds.test.ts +80 -0
  13. package/src/notifications/__tests__/registry.test.ts +184 -0
  14. package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +62 -29
  15. package/src/notifications/__tests__/renderer.test.ts +181 -0
  16. package/src/notifications/content.ts +249 -0
  17. package/src/notifications/context.ts +70 -0
  18. package/src/notifications/definition.ts +82 -0
  19. package/src/notifications/index.ts +104 -0
  20. package/src/notifications/kinds/README.md +57 -0
  21. package/src/notifications/kinds/auth-otp.ts +99 -0
  22. package/src/notifications/kinds/chat-shared.ts +56 -0
  23. package/src/notifications/kinds/company-md-access-approved.ts +57 -0
  24. package/src/notifications/kinds/company-md-access-denied.ts +61 -0
  25. package/src/notifications/kinds/company-md-access-requested.ts +65 -0
  26. package/src/notifications/kinds/index.ts +19 -0
  27. package/src/notifications/kinds/org-invite.ts +62 -0
  28. package/src/notifications/kinds/ownership-transfer-completed.ts +57 -0
  29. package/src/notifications/kinds/ownership-transfer.ts +68 -0
  30. package/src/notifications/kinds/security-alert.ts +78 -0
  31. package/src/notifications/kinds/share-granted.ts +74 -0
  32. package/src/notifications/kinds/unit-owner-granted.ts +95 -0
  33. package/src/notifications/kinds.ts +68 -0
  34. package/src/{email/types.ts → notifications/payloads.ts} +33 -68
  35. package/src/notifications/registry.ts +107 -0
  36. package/src/notifications/render.ts +106 -0
  37. package/src/notifications/renderer.ts +50 -0
  38. package/src/notifications/renderers/README.md +50 -0
  39. package/src/notifications/renderers/email/README.md +78 -0
  40. package/src/notifications/renderers/email/__tests__/README.md +32 -0
  41. package/src/notifications/renderers/email/__tests__/render.test.ts +188 -0
  42. package/src/{email/render → notifications/renderers/email}/chat.ts +54 -107
  43. package/src/notifications/renderers/email/constants.ts +64 -0
  44. package/src/notifications/renderers/email/cta.ts +63 -0
  45. package/src/{email/render → notifications/renderers/email}/escape-html.ts +5 -1
  46. package/src/notifications/renderers/email/index.ts +73 -0
  47. package/src/notifications/renderers/email/render.ts +238 -0
  48. package/src/notifications/renderers/email/shells.ts +61 -0
  49. package/src/notifications/renderers/slack/README.md +73 -0
  50. package/src/notifications/renderers/slack/__tests__/README.md +33 -0
  51. package/src/notifications/renderers/slack/__tests__/index.test.ts +201 -0
  52. package/src/notifications/renderers/slack/index.ts +261 -0
  53. package/src/notifications/renderers/sms/README.md +69 -0
  54. package/src/notifications/renderers/sms/__tests__/README.md +31 -0
  55. package/src/notifications/renderers/sms/__tests__/index.test.ts +162 -0
  56. package/src/notifications/renderers/sms/index.ts +131 -0
  57. package/src/notifications/text.ts +52 -0
  58. package/src/email/README.md +0 -51
  59. package/src/email/__tests__/registry.test.ts +0 -161
  60. package/src/email/index.ts +0 -36
  61. package/src/email/registry.ts +0 -155
  62. package/src/email/render/auth-otp.ts +0 -71
  63. package/src/email/render/blocks.ts +0 -281
  64. package/src/email/render/chat-shared.ts +0 -35
  65. package/src/email/render/company-md-access-approved.ts +0 -42
  66. package/src/email/render/company-md-access-denied.ts +0 -43
  67. package/src/email/render/company-md-access-requested.ts +0 -46
  68. package/src/email/render/constants.ts +0 -18
  69. package/src/email/render/index.ts +0 -58
  70. package/src/email/render/org-invite.ts +0 -40
  71. package/src/email/render/ownership-transfer-completed.ts +0 -41
  72. package/src/email/render/ownership-transfer.ts +0 -42
  73. package/src/email/render/render-email.ts +0 -194
  74. package/src/email/render/security-alert.ts +0 -61
  75. package/src/email/render/share-granted.ts +0 -52
  76. package/src/email/render/unit-owner-granted.ts +0 -60
@@ -0,0 +1,78 @@
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 locked by `../../__tests__/render-snapshot.test.ts`, across every
52
+ fixture in `../../__tests__/fixtures.ts` — a diff there means a real sent email
53
+ changed, so review it before regenerating. Byte-identity with the old
54
+ `src/email/render` was the whole constraint of the migration; it held, shipped
55
+ in 35.0.0, and ADR-CONTRACTS-087 retired the golden that proved it, because
56
+ that proof could not double as permission to change the design.
57
+ - `__tests__/render.test.ts` asserts the rules that outlive any redesign — every
58
+ element type supported, subject from the content's title, year and signer from
59
+ context, the `:` restored, user content escaped. Markup belongs in the
60
+ snapshot, not there.
61
+ - PURE. `render` is a function of `(content, context)`: no clock, no environment,
62
+ no I/O. The copyright year comes from `context.brand.copyrightYear`, which is
63
+ what retires the `new Date().getFullYear()` hidden inside the old
64
+ `signature()`.
65
+ - `supports` answers true for EVERY element type. Email is the rich channel;
66
+ declining an element would be a lie about the channel rather than a fact about
67
+ the notification.
68
+ - The `:` between a label and its value is presentation and lives in no element
69
+ (see `../../kinds/README.md`). `render.ts` puts it back — on key/value rows and
70
+ on the metadata heading.
71
+ - Values arrive pre-formatted: dates, title-casing and truncation are `compose`'s
72
+ work. Nothing here re-formats a value.
73
+ - Every user-controlled field in the HTML surface passes through `escapeHtml`
74
+ before interpolation.
75
+ - `MONO` / `FONT_SIZE` / `SPACING` are this channel's own and are NOT exported
76
+ from the domain barrel: a margin is not vocabulary. They were duplicated from
77
+ the old `email/render/blocks`'s private copies while both layers existed; that
78
+ duplication ended with the directory, and these are now the only copies.
@@ -0,0 +1,32 @@
1
+ # notifications/renderers/email/\_\_tests\_\_/
2
+
3
+ ## Purpose
4
+
5
+ Tests for the email channel (ADR-CONTRACTS-086).
6
+
7
+ `render.test.ts` pins the renderer's behavioural rules — the claims that hold no
8
+ matter how the email is styled: that `supports` is total, that the subject comes
9
+ from the content's title, that the year and signer come from `RenderContext`
10
+ rather than a clock, that the `:` separator the content model drops is put back,
11
+ and that user-controlled fields are escaped. It also renders every fixture in
12
+ `../../../__tests__/fixtures.ts` through `NOTIFICATION_DEFINITIONS`, which proves
13
+ each kind composes and renders on both surfaces at all.
14
+
15
+ The MARKUP is not here. `../../../__tests__/render-snapshot.test.ts` locks that.
16
+
17
+ ## Invariants
18
+
19
+ - Rules here, markup there. A test in this file that asserts what the HTML looks
20
+ like is in the wrong file: it belongs in the snapshot, where a redesign shows
21
+ up as a reviewable diff instead of a wall of broken assertions.
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
+ - This file used to assert every fixture byte-for-byte against a frozen golden of
27
+ what `src/email/render` emitted at 32.0.0 — the migration's "no rendered email
28
+ changed" promise, checked here at the RENDERER and in a companion suite at the
29
+ ENTRY POINT (`renderToChannel`). The promise held and shipped in 35.0.0;
30
+ ADR-CONTRACTS-087 retired both, because byte-equality with a deleted layer
31
+ cannot survive email design being deliberately changed. The bytes are in git at
32
+ `v35.0.0`.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The email renderer's behavioural rules (ADR-CONTRACTS-086) — the claims that
3
+ * hold no matter how the email is styled: it supports every element type, takes
4
+ * the subject from the content's title, takes the year and signer from context
5
+ * rather than a clock, restores the `:` the content model does not carry, and
6
+ * escapes user-controlled text.
7
+ *
8
+ * The MARKUP is not asserted here. `../../../__tests__/render-snapshot.test.ts`
9
+ * locks that, as a reviewable regenerable diff. Until ADR-CONTRACTS-087 this
10
+ * file also asserted every fixture byte-for-byte against the 32.0.0 golden, to
11
+ * prove the multi-channel generalisation moved no bytes; that proof shipped in
12
+ * 35.0.0 and was retired with the golden, because email design is now allowed to
13
+ * change and byte-equality to a deleted layer cannot survive it being exercised.
14
+ *
15
+ * There is no fake timer here, and that is the improvement being locked in: the
16
+ * year comes from `RenderContext`, so the fixtures' clock is just a number.
17
+ */
18
+
19
+ import { describe, expect, it } from "vitest";
20
+
21
+ import type {
22
+ NotificationContent,
23
+ NotificationElementType,
24
+ } from "../../../content";
25
+ import type { RenderContext } from "../../../context";
26
+ import { NOTIFICATION_DEFINITIONS } from "../../../registry";
27
+ import {
28
+ FIXTURE_CLOCK,
29
+ NOTIFICATION_FIXTURES,
30
+ fixtureKey,
31
+ } from "../../../__tests__/fixtures";
32
+ import { emailRenderer } from "../index";
33
+
34
+ /**
35
+ * The fixtures' context, stated rather than ticked. The old layer reached for
36
+ * `new Date().getFullYear()` mid-render, which is why every render test had to
37
+ * freeze the clock; here the year is simply a value.
38
+ */
39
+ const FIXTURE_CONTEXT: RenderContext = {
40
+ brand: {
41
+ name: "Company Semantics",
42
+ copyrightYear: new Date(FIXTURE_CLOCK).getUTCFullYear(),
43
+ },
44
+ };
45
+
46
+ const ALL_TYPES: NotificationElementType[] = [
47
+ "greeting",
48
+ "body",
49
+ "keyValueTable",
50
+ "callToAction",
51
+ "list",
52
+ "divider",
53
+ "warning",
54
+ "metadata",
55
+ "notice",
56
+ "chatUnit",
57
+ "signature",
58
+ "heroImage",
59
+ ];
60
+
61
+ describe("emailRenderer", () => {
62
+ it("declares the email channel", () => {
63
+ expect(emailRenderer.id).toBe("email");
64
+ });
65
+
66
+ it("supports every element type — email is the rich channel", () => {
67
+ for (const type of ALL_TYPES) {
68
+ expect(emailRenderer.supports(type), type).toBe(true);
69
+ }
70
+ });
71
+
72
+ it("renders the subject from the content's title, not a registry", () => {
73
+ const content: NotificationContent = {
74
+ metadata: { kind: "org.invite", title: "Join Acme" },
75
+ sections: [{ elements: [{ type: "body", text: "Hello." }] }],
76
+ };
77
+ expect(emailRenderer.render(content, FIXTURE_CONTEXT).subject).toBe(
78
+ "Join Acme",
79
+ );
80
+ });
81
+
82
+ it("takes the copyright year and signer from context, never from a clock", () => {
83
+ const content: NotificationContent = {
84
+ metadata: { kind: "org.invite", title: "t" },
85
+ sections: [{ elements: [{ type: "signature" }] }],
86
+ };
87
+ const pinned: RenderContext = {
88
+ brand: { name: "Acme", copyrightYear: 1999 },
89
+ };
90
+ expect(emailRenderer.render(content, pinned).text).toContain(
91
+ "ⓒ 1999 • Acme",
92
+ );
93
+ });
94
+
95
+ it("lets an element's signer override the context brand", () => {
96
+ const content: NotificationContent = {
97
+ metadata: { kind: "org.invite", title: "t" },
98
+ sections: [{ elements: [{ type: "signature", signer: "Grace" }] }],
99
+ };
100
+ expect(emailRenderer.render(content, FIXTURE_CONTEXT).text).toContain(
101
+ "• Grace",
102
+ );
103
+ });
104
+
105
+ it("puts back the ':' the content model does not carry", () => {
106
+ // The separator is presentation and lives in no element — a renderer that
107
+ // forgets it silently drops the colon from every fact in every email.
108
+ const content: NotificationContent = {
109
+ metadata: { kind: "auth.otp", title: "t" },
110
+ sections: [
111
+ {
112
+ elements: [
113
+ {
114
+ type: "keyValueTable",
115
+ rows: [{ label: "Status", value: "Valid" }],
116
+ },
117
+ {
118
+ type: "metadata",
119
+ label: "Request details",
120
+ rows: [{ label: "IP address", value: "203.0.113.1" }],
121
+ },
122
+ ],
123
+ },
124
+ ],
125
+ };
126
+ const { text } = emailRenderer.render(content, FIXTURE_CONTEXT);
127
+ expect(text).toContain("Status: Valid");
128
+ expect(text).toContain("Request details:");
129
+ expect(text).toContain("IP address: 203.0.113.1");
130
+ });
131
+
132
+ it("escapes user-controlled content in the HTML surface", () => {
133
+ const content: NotificationContent = {
134
+ metadata: { kind: "org.invite", title: "t" },
135
+ sections: [
136
+ {
137
+ elements: [{ type: "greeting", recipientName: "<script>x</script>" }],
138
+ },
139
+ ],
140
+ };
141
+ const { html } = emailRenderer.render(content, FIXTURE_CONTEXT);
142
+ expect(html).toContain("&lt;script&gt;");
143
+ expect(html).not.toContain("<script>");
144
+ });
145
+
146
+ it("is pure — same inputs, same bytes", () => {
147
+ const content = NOTIFICATION_DEFINITIONS["security.alert"].compose(
148
+ {
149
+ alertType: "excessive_otp_requests",
150
+ details: "5 codes in 2 minutes",
151
+ timestamp: "2026-07-15T00:00:00.000Z",
152
+ },
153
+ FIXTURE_CONTEXT,
154
+ );
155
+ expect(emailRenderer.render(content, FIXTURE_CONTEXT)).toEqual(
156
+ emailRenderer.render(content, FIXTURE_CONTEXT),
157
+ );
158
+ });
159
+
160
+ describe("renders every fixture", () => {
161
+ // Was a byte-for-byte assertion against the 32.0.0 golden until
162
+ // ADR-CONTRACTS-087 retired it. The markup itself is locked by
163
+ // `../../../__tests__/render-snapshot.test.ts`; what is worth proving HERE
164
+ // is narrower and survives a redesign — that every kind in the registry
165
+ // composes and renders through this channel at all, on both surfaces. A kind
166
+ // whose `compose` reaches for a field its payload lacks fails here, at the
167
+ // renderer, rather than in whichever consumer sends it first.
168
+ for (const fixture of NOTIFICATION_FIXTURES) {
169
+ const key = fixtureKey(fixture.kind, fixture.name);
170
+ it(`${key}`, () => {
171
+ const definition = NOTIFICATION_DEFINITIONS[fixture.kind];
172
+ // `payload as never`: the fixture call sites are type-checked in the
173
+ // fixtures module; this only bridges compose's per-kind payload.
174
+ const content = definition.compose(
175
+ fixture.payload as never,
176
+ FIXTURE_CONTEXT,
177
+ );
178
+ const { subject, text, html } = emailRenderer.render(
179
+ content,
180
+ FIXTURE_CONTEXT,
181
+ );
182
+ expect(subject).toBeTruthy();
183
+ expect(text).toBeTruthy();
184
+ expect(html).toContain("<!DOCTYPE html>");
185
+ });
186
+ }
187
+ });
188
+ });
@@ -1,32 +1,25 @@
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. The box art is real output, and every column of it is locked by
12
+ * `../../__tests__/render-snapshot.test.ts` a stray space is a visibly
13
+ * broken email, not a whitespace nit.
14
+ * - Both surfaces truncate at the same point — `clampMessage` is the one
15
+ * truncation authority, and HTML and plain text both run content through it.
16
16
  */
17
17
 
18
- import {
19
- type Block,
20
- type CtaBoxOptions,
21
- ctaButton,
22
- FONT_SIZE,
23
- MONO,
24
- } from "./blocks";
25
- import { escapeHtml } from "./escape-html";
18
+ import type { CallToAction, ChatTurn, ChatUnitItem } from "../../content";
26
19
 
27
- // =============================================================================
28
- // Text geometry
29
- // =============================================================================
20
+ import { FONT_SIZE, MONO } from "./constants";
21
+ import { ctaButton } from "./cta";
22
+ import { escapeHtml } from "./escape-html";
30
23
 
31
24
  /** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
32
25
  function wrapText(text: string, width: number): string[] {
@@ -74,65 +67,13 @@ function wrapClamped(text: string, width: number, maxLines: number): string[] {
74
67
 
75
68
  /**
76
69
  * 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
70
+ * MESSAGE_WIDTH, ellipsized. Both surfaces of a chat unit run content through
78
71
  * this, so HTML and plain text truncate at exactly the same point.
79
72
  */
80
73
  function clampMessage(text: string): string {
81
74
  return wrapClamped(text, MESSAGE_WIDTH, MAX_MESSAGE_LINES).join(" ");
82
75
  }
83
76
 
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
77
  /** The `<hr>` bracketing a chat unit — 24px toward the bubbles, 12px on the
137
78
  * outer side. */
138
79
  function chatRuleHtml(position: "top" | "bottom"): string {
@@ -141,18 +82,18 @@ function chatRuleHtml(position: "top" | "bottom"): string {
141
82
  }
142
83
 
143
84
  /**
144
- * Render one message to its HTML `<table>` and plain-text box lines. Both roles
85
+ * Render one turn to its HTML `<table>` and plain-text box lines. Both roles
145
86
  * share the 3-column skeleton: a fixed avatar column on each side (the visible
146
87
  * avatar plus the opposite avatar rendered `visibility: hidden` to reserve its
147
88
  * width, so bubbles stay bounded and aligned), a middle cell that right/left-
148
89
  * aligns the bubble, and — for a user `from` — an attribution row below.
149
90
  */
150
91
  function renderBubble(
151
- msg: ChatMessage,
92
+ turn: ChatTurn,
152
93
  margin: string,
153
94
  ): { html: string; text: string[] } {
154
- const clamped = clampMessage(msg.text);
155
- const isUser = msg.role === "user";
95
+ const clamped = clampMessage(turn.text);
96
+ const isUser = turn.role === "user";
156
97
 
157
98
  const radius = isUser ? "8px 8px 0 8px" : "8px 8px 8px 0";
158
99
  const bubbleAlign = isUser ? " text-align: right;" : "";
@@ -161,11 +102,11 @@ function renderBubble(
161
102
  const kaomojiHidden = isUser ? "" : "visibility: hidden; ";
162
103
 
163
104
  const attributionRow =
164
- isUser && msg.from
105
+ isUser && turn.from
165
106
  ? `
166
107
  <tr>
167
108
  <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>
109
+ <td style="${MONO} font-size: ${FONT_SIZE}; color: #666; text-align: right; padding-top: 6px; padding-right: 1ch;">${escapeHtml(turn.from)}</td>
169
110
  <td></td>
170
111
  </tr>`
171
112
  : "";
@@ -196,8 +137,8 @@ function renderBubble(
196
137
  ...body,
197
138
  `${CHAT_INDENT}└${border}┘`,
198
139
  ];
199
- if (isUser && msg.from) {
200
- box.push(msg.from.padStart(CHAT_INDENT.length + MESSAGE_WIDTH + 3));
140
+ if (isUser && turn.from) {
141
+ box.push(turn.from.padStart(CHAT_INDENT.length + MESSAGE_WIDTH + 3));
201
142
  }
202
143
  return { html, text: box };
203
144
  }
@@ -219,7 +160,7 @@ function dotsOverCtaHtml(): string {
219
160
  * When `withDots`, continuation "⋮" render just above the button, centered over it.
220
161
  */
221
162
  function renderChatCta(
222
- cta: CtaBoxOptions,
163
+ cta: CallToAction,
223
164
  align: "left" | "right",
224
165
  withDots: boolean,
225
166
  ): { html: string; text: string[] } {
@@ -259,8 +200,8 @@ function renderChatCta(
259
200
 
260
201
  /**
261
202
  * 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).
203
+ * a `continuation` is NOT immediately followed by a `callToAction` (the common
204
+ * case folds the dots into the CTA via `renderChatCta`, centered over the box).
264
205
  */
265
206
  function renderChatDots(): { html: string; text: string[] } {
266
207
  const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 16px 0;">
@@ -275,57 +216,63 @@ function renderChatDots(): { html: string; text: string[] } {
275
216
  return { html, text: ["⋮".padStart(center)] };
276
217
  }
277
218
 
278
- // =============================================================================
279
- // Chat unit
280
- // =============================================================================
281
-
282
219
  /**
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
220
+ * Lay out a chat unit's items — bubbles and/or CTA buttons — as a single
221
+ * rendered thing: a rule above and below (the real-chat "unit"), the items
285
222
  * 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.
223
+ * top rule and the last item hugs the bottom rule. Continuation dots sit above
224
+ * the CTA they precede, centered over the box.
288
225
  */
289
- export function chatUnit(...items: ChatItem[]): Block {
226
+ export function renderChatUnit(items: ChatUnitItem[]): {
227
+ html: string;
228
+ text: string;
229
+ } {
290
230
  const parts: { html: string; text: string[] }[] = [];
291
231
  items.forEach((item, i) => {
292
- if (item.role === "dots") {
232
+ if (item.type === "continuation") {
293
233
  // Dots immediately before a CTA render with it (centered over the box);
294
234
  // otherwise fall back to channel-centered standalone dots.
295
- if (items[i + 1]?.role !== "cta") parts.push(renderChatDots());
235
+ if (items[i + 1]?.type !== "callToAction") parts.push(renderChatDots());
296
236
  return;
297
237
  }
298
- if (item.role === "cta") {
238
+ if (item.type === "callToAction") {
299
239
  // Mirror the side of the nearest preceding message (skip any dots between),
300
240
  // so the CTA sits under the bubble it belongs to — right under a user.
301
241
  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"));
242
+ while (j >= 0 && items[j].type === "continuation") j--;
243
+ const prev = items[j];
244
+ const align =
245
+ prev?.type === "message" && prev.role === "user" ? "right" : "left";
246
+ parts.push(
247
+ renderChatCta(item, align, items[i - 1]?.type === "continuation"),
248
+ );
305
249
  return;
306
250
  }
307
251
  // A bubble directly above a CTA or continuation dots gets a tighter 16px
308
252
  // gap; else 24px.
309
- const next = items[i + 1]?.role;
253
+ const next = items[i + 1]?.type;
310
254
  const margin =
311
- next === "cta" || next === "dots" ? "0 0 16px 0" : "0 0 24px 0";
255
+ next === "callToAction" || next === "continuation"
256
+ ? "0 0 16px 0"
257
+ : "0 0 24px 0";
312
258
  parts.push(renderBubble(item, margin));
313
259
  });
260
+
314
261
  const width = parts
315
- .flatMap((b) => b.text)
262
+ .flatMap((part) => part.text)
316
263
  .reduce((w, l) => Math.max(w, l.length), 0);
317
264
  const rule = "_".repeat(width);
318
265
 
319
266
  const html = [
320
267
  chatRuleHtml("top"),
321
- ...parts.map((b) => b.html),
268
+ ...parts.map((part) => part.html),
322
269
  chatRuleHtml("bottom"),
323
270
  ].join("\n");
324
271
 
325
272
  const text =
326
273
  `${rule}\n\n` +
327
- parts.map((b) => b.text.join("\n")).join("\n\n") +
274
+ parts.map((part) => part.text.join("\n")).join("\n\n") +
328
275
  `\n${rule}`;
329
276
 
330
- return { html, text, spacing: "normal" };
277
+ return { html, text };
331
278
  }
@@ -0,0 +1,64 @@
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 colour for a `warning` — the product's destructive token, resolved.
38
+ *
39
+ * This is `--destructive` from the app's `globals.css` `:root`
40
+ * (`oklch(0.577 0.245 27.325)`) converted to sRGB hex. It is COPIED, not
41
+ * imported, and it has to be: contracts sits ABOVE the app in the dependency
42
+ * flow, so this package cannot read the app's stylesheet — and email cannot read
43
+ * one either. There is no CSS to cascade from and no custom properties to
44
+ * resolve, so every colour in this channel is a literal in an inline `style`.
45
+ * If the token moves, this does not follow it; that divergence is the cost of
46
+ * the arrow pointing one way.
47
+ *
48
+ * The `:root` value rather than `.dark`'s, because this channel is light-only by
49
+ * construction — `./shells.ts` states `color: #1a1a1a` on the body.
50
+ */
51
+ export const DESTRUCTIVE = "#e7000b";
52
+
53
+ /**
54
+ * The gap AFTER a rendered line: `"normal"` is a blank line in plain text,
55
+ * anything else is none.
56
+ */
57
+ export type Spacing = "normal" | "tight" | "none";
58
+
59
+ /** The HTML margin each `Spacing` maps to. */
60
+ export const SPACING: Record<Spacing, string> = {
61
+ normal: "0 0 20px 0",
62
+ tight: "0 0 4px 0",
63
+ none: "0",
64
+ };