@company-semantics/contracts 35.1.0 → 36.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.
@@ -20,6 +20,7 @@ import { describe, expect, it } from "vitest";
20
20
 
21
21
  import type {
22
22
  NotificationContent,
23
+ NotificationElement,
23
24
  NotificationElementType,
24
25
  } from "../../../content";
25
26
  import type { RenderContext } from "../../../context";
@@ -143,6 +144,91 @@ describe("emailRenderer", () => {
143
144
  expect(html).not.toContain("<script>");
144
145
  });
145
146
 
147
+ it("escapes a URL, so it cannot close its attribute and forge another", () => {
148
+ // `href` and `src` are the two values that reach the markup as a URL rather
149
+ // than as text, and they used to be interpolated raw — an HTML injection on
150
+ // its own, and since ADR-CONTRACTS-089 a style injection too: `inlineStyles`
151
+ // rewrites class attributes, so a URL that can write `class="csr-body"` can
152
+ // write any declaration in the registry into someone else's element.
153
+ const html = (element: NotificationElement) =>
154
+ emailRenderer.render(
155
+ {
156
+ metadata: { kind: "auth.otp", title: "t" },
157
+ sections: [{ elements: [element] }],
158
+ },
159
+ FIXTURE_CONTEXT,
160
+ ).html;
161
+
162
+ const cta = html({
163
+ type: "callToAction",
164
+ label: "Open",
165
+ href: 'https://x.test/" data-forged="1',
166
+ });
167
+ // The quote survives as text, so the forged attribute never becomes one.
168
+ // Asserted against `data-forged="` — the ATTRIBUTE form — because the escaped
169
+ // payload still contains the string `data-forged=`, and a looser match would
170
+ // pass whether or not the fix is present.
171
+ expect(cta).toContain("&quot;");
172
+ expect(cta).not.toMatch(/data-forged="/);
173
+
174
+ const hero = html({
175
+ type: "heroImage",
176
+ src: '/x.png" onerror="alert(1)',
177
+ alt: "x",
178
+ });
179
+ expect(hero).not.toMatch(/onerror="/);
180
+ });
181
+
182
+ it("makes a linked CTA's whole box the click target, an unlinked one not clickable", () => {
183
+ // The click target is the anchor, so it must fill the box — `display: block` +
184
+ // the padding on the `<a>` itself, not the cell. An OTP code has no href: it
185
+ // must not become an anchor at all, which is what keeps it uncopiable-as-a-link
186
+ // and unclickable.
187
+ const box = (cta: NotificationElement) =>
188
+ emailRenderer.render(
189
+ {
190
+ metadata: { kind: "auth.otp", title: "t" },
191
+ sections: [{ elements: [cta] }],
192
+ },
193
+ FIXTURE_CONTEXT,
194
+ ).html;
195
+
196
+ const linked = box({
197
+ type: "callToAction",
198
+ label: "Open",
199
+ href: "https://example.com/x",
200
+ });
201
+ expect(linked).toMatch(
202
+ /<a [^>]*href="https:\/\/example\.com\/x"[^>]*style="display: block; padding:/,
203
+ );
204
+
205
+ const payload = box({ type: "callToAction", label: "123456" });
206
+ expect(payload).not.toContain("<a ");
207
+ // The cell carries the padding instead, so the code is still a padded box.
208
+ expect(payload).toMatch(/<td style="padding: /);
209
+ });
210
+
211
+ it("restores the CTA's cell padding for Outlook, which cannot fill the anchor", () => {
212
+ // Outlook's Word engine ignores `display: block`, so the full-box target
213
+ // degrades to today's text click there. The MSO block must put the padding
214
+ // back on the cell, or Outlook renders the button cramped.
215
+ const { html } = emailRenderer.render(
216
+ {
217
+ metadata: { kind: "org.invite", title: "t" },
218
+ sections: [
219
+ {
220
+ elements: [
221
+ { type: "callToAction", label: "Join", href: "https://x.test" },
222
+ ],
223
+ },
224
+ ],
225
+ },
226
+ FIXTURE_CONTEXT,
227
+ );
228
+ expect(html).toContain("<!--[if mso]>");
229
+ expect(html).toMatch(/\[if mso\]>.*\.cs-cta td \{ padding: .* \}/s);
230
+ });
231
+
146
232
  it("is pure — same inputs, same bytes", () => {
147
233
  const content = NOTIFICATION_DEFINITIONS["security.alert"].compose(
148
234
  {
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The style registry's contract (ADR-CONTRACTS-089).
3
+ *
4
+ * `../../__tests__/render-snapshot.test.ts` already proves the html surface's
5
+ * bytes, and it is the authority on what the email LOOKS like. What it cannot say
6
+ * is WHY those bytes are safe to derive, because a snapshot passes just as happily
7
+ * over markup that leaks a recipe class or drops a role hook. This file asserts the
8
+ * two properties the derivation rests on:
9
+ *
10
+ * - a `cs-` role hook SHIPS, because `darkStyle` needs it in the markup;
11
+ * - a `csr-` recipe class DOES NOT, because it is spent and dropped.
12
+ *
13
+ * Get either backwards and the snapshot still passes while dark mode silently
14
+ * stops working, or the shipped email carries dead classes nothing styles.
15
+ */
16
+
17
+ import { describe, expect, it } from "vitest";
18
+
19
+ import type { NotificationContent } from "../../../content";
20
+ import type { RenderContext } from "../../../context";
21
+ import { emailRenderer } from "../index";
22
+ import { inlineOf, inlineStyles, ruleOf, STYLE_NAMES } from "../styles";
23
+
24
+ const CONTEXT: RenderContext = {
25
+ brand: { name: "Company Semantics", copyrightYear: 2026 },
26
+ };
27
+
28
+ /** One of everything that carries a class, so the scan below has something to see. */
29
+ const CONTENT: NotificationContent = {
30
+ metadata: { kind: "auth.otp", title: "t" },
31
+ sections: [
32
+ {
33
+ elements: [
34
+ { type: "greeting", recipientName: "Sam" },
35
+ { type: "warning" },
36
+ { type: "divider" },
37
+ { type: "callToAction", label: "Open", href: "https://x.test/a" },
38
+ { type: "callToAction", label: "123456" },
39
+ {
40
+ type: "chatUnit",
41
+ items: [
42
+ { type: "message", role: "user", text: "hi", from: "Sam" },
43
+ { type: "message", role: "assistant", text: "hello" },
44
+ { type: "continuation" },
45
+ { type: "callToAction", label: "Read", href: "https://x.test/b" },
46
+ ],
47
+ },
48
+ { type: "signature" },
49
+ ],
50
+ },
51
+ ],
52
+ };
53
+
54
+ /** Every class the shipped markup actually applies. */
55
+ function shippedClasses(html: string): string[] {
56
+ return [...html.matchAll(/class="([^"]+)"/g)].flatMap((m) =>
57
+ m[1].split(/\s+/),
58
+ );
59
+ }
60
+
61
+ describe("the style registry", () => {
62
+ it("ships no recipe class — they are spent by the inliner, not delivered", () => {
63
+ const { html } = emailRenderer.render(CONTENT, CONTEXT);
64
+ // The whole document, not just the class attributes: a recipe class must not
65
+ // survive anywhere, including in a stylesheet the html surface never wants.
66
+ expect(html).not.toContain("csr-");
67
+ });
68
+
69
+ it("ships every role hook, because the dark stylesheet needs it there", () => {
70
+ const { html } = emailRenderer.render(CONTENT, CONTEXT);
71
+ const shipped = shippedClasses(html);
72
+ expect(shipped.length).toBeGreaterThan(0);
73
+ // A sample across the roles this content exercises. `cs-cta-hover` is the
74
+ // interesting one: it is the only reason `.cs-cta` and the hover state are
75
+ // separate roles, and it rides on the linked CTA above.
76
+ for (const hook of ["cs-link", "cs-cta", "cs-cta-hover", "cs-bubble"]) {
77
+ expect(shipped, `${hook} never reached the markup`).toContain(hook);
78
+ }
79
+ });
80
+
81
+ it("turns a recipe-only class attribute into a style, leaving no class behind", () => {
82
+ // The collapse that makes `<p class="csr-p-none">` render as the `<p style="…">`
83
+ // the channel shipped before this registry existed. A leftover `class=""` would
84
+ // be invisible in a client and a permanent diff in the snapshot.
85
+ expect(inlineStyles(`<p class="csr-p-none">x</p>`)).toBe(
86
+ `<p style="${inlineOf("p-none")}">x</p>`,
87
+ );
88
+ });
89
+
90
+ it("keeps role hooks and appends the style, in that order", () => {
91
+ // Order is not cosmetic: it is what makes the inliner reproduce the previous
92
+ // markup byte-for-byte rather than merely equivalently.
93
+ expect(inlineStyles(`<td class="cs-bubble csr-bubble-user">x</td>`)).toBe(
94
+ `<td class="cs-bubble" style="${inlineOf("bubble-user")}">x</td>`,
95
+ );
96
+ });
97
+
98
+ it("leaves markup with no classes untouched", () => {
99
+ const plain = `<tr><td></td></tr>`;
100
+ expect(inlineStyles(plain)).toBe(plain);
101
+ });
102
+
103
+ it("states each recipe as a rule and as an inline style, from one source", () => {
104
+ // The two surfaces' only difference is the shape, never the content — which is
105
+ // the claim that lets `ampShell` and `htmlShell` share this registry.
106
+ for (const name of STYLE_NAMES) {
107
+ expect(ruleOf(name)).toBe(`.csr-${name} { ${inlineOf(name)} }`);
108
+ expect(inlineOf(name).endsWith(";")).toBe(true);
109
+ }
110
+ });
111
+ });
@@ -17,9 +17,9 @@
17
17
 
18
18
  import type { CallToAction, ChatTurn, ChatUnitItem } from "../../content";
19
19
 
20
- import { FONT_SIZE, MONO } from "./constants";
21
20
  import { ctaButton } from "./cta";
22
21
  import { escapeHtml } from "./escape-html";
22
+ import { styleClass } from "./styles";
23
23
 
24
24
  /** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
25
25
  function wrapText(text: string, width: number): string[] {
@@ -53,6 +53,17 @@ const MESSAGE_WIDTH = 36;
53
53
  * user box aligns with the assistant box. */
54
54
  const CHAT_INDENT = " ";
55
55
 
56
+ /**
57
+ * The gap under a chat row: 24px clears the bubbles, 16px hugs whatever the row
58
+ * is introducing (a CTA, the dots).
59
+ *
60
+ * A recipe name rather than a margin string, because a margin cannot be inline on
61
+ * the AMP surface (ADR-CONTRACTS-089) — and naming the two the layout actually has
62
+ * is what makes a third one a deliberate addition to `./styles.ts` rather than a
63
+ * new string appearing at a call site.
64
+ */
65
+ type ChatRow = "chat-row-16" | "chat-row-24";
66
+
56
67
  /** Word-wrap `text`, then clamp to `maxLines`, ellipsizing the last line on overflow. */
57
68
  function wrapClamped(text: string, width: number, maxLines: number): string[] {
58
69
  const lines = wrapText(text, width);
@@ -77,8 +88,7 @@ function clampMessage(text: string): string {
77
88
  /** The `<hr>` bracketing a chat unit — 24px toward the bubbles, 12px on the
78
89
  * outer side. */
79
90
  function chatRuleHtml(position: "top" | "bottom"): string {
80
- const margin = position === "top" ? "12px 0 24px 0" : "24px 0 12px 0";
81
- return `<hr style="border: none; border-top: 1px solid #bbb; margin: ${margin};">`;
91
+ return `<hr ${styleClass(`chat-rule-${position}`, "faint")}>`;
82
92
  }
83
93
 
84
94
  /**
@@ -90,34 +100,35 @@ function chatRuleHtml(position: "top" | "bottom"): string {
90
100
  */
91
101
  function renderBubble(
92
102
  turn: ChatTurn,
93
- margin: string,
103
+ row: ChatRow,
94
104
  ): { html: string; text: string[] } {
95
105
  const clamped = clampMessage(turn.text);
96
106
  const isUser = turn.role === "user";
97
107
 
98
- const radius = isUser ? "8px 8px 0 8px" : "8px 8px 8px 0";
99
- const bubbleAlign = isUser ? " text-align: right;" : "";
100
- const cellAlign = isUser ? "right" : "left";
101
- const csHidden = isUser ? "visibility: hidden; " : "";
102
- const kaomojiHidden = isUser ? "" : "visibility: hidden; ";
108
+ // Each side draws its own avatar and hides the other's, which is what reserves
109
+ // both columns and keeps the two bubbles aligned.
110
+ const bubble = isUser ? "bubble-user" : "bubble-assistant";
111
+ const channel = isUser ? "chat-channel-right" : "chat-channel-left";
112
+ const avatar = isUser ? "chat-avatar-left-hidden" : "chat-avatar-left";
113
+ const kaomoji = isUser ? "chat-avatar-right" : "chat-avatar-right-hidden";
103
114
 
104
115
  const attributionRow =
105
116
  isUser && turn.from
106
117
  ? `
107
118
  <tr>
108
119
  <td></td>
109
- <td style="${MONO} font-size: ${FONT_SIZE}; color: #666; text-align: right; padding-top: 6px; padding-right: 1ch;">${escapeHtml(turn.from)}</td>
120
+ <td ${styleClass("chat-attribution", "meta")}>${escapeHtml(turn.from)}</td>
110
121
  <td></td>
111
122
  </tr>`
112
123
  : "";
113
124
 
114
- const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: ${margin};">
125
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" ${styleClass(row)}>
115
126
  <tr>
116
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; ${csHidden}white-space: nowrap;">[c_S]</td>
117
- <td style="width: 100%; text-align: ${cellAlign};"><table cellpadding="0" cellspacing="0" border="0" style="display: inline-block; max-width: 100%; vertical-align: bottom;">
118
- <tr><td style="border-radius: ${radius}; padding: 10px 14px;${bubbleAlign} ${MONO} font-size: ${FONT_SIZE}; color: #ffffff; background: #666;">${escapeHtml(clamped)}</td></tr>
127
+ <td ${styleClass(avatar)}>[c_S]</td>
128
+ <td ${styleClass(channel)}><table cellpadding="0" cellspacing="0" border="0" ${styleClass("chat-bubble-wrap")}>
129
+ <tr><td ${styleClass(bubble, "bubble")}>${escapeHtml(clamped)}</td></tr>
119
130
  </table></td>
120
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; ${kaomojiHidden}white-space: nowrap;">(•̀_ರ╮)</td>
131
+ <td ${styleClass(kaomoji)}>(•̀_ರ╮)</td>
121
132
  </tr>${attributionRow}
122
133
  </table>`;
123
134
 
@@ -149,7 +160,7 @@ const CHAT_RIGHT_EDGE = CHAT_INDENT.length + MESSAGE_WIDTH + 4;
149
160
  /** Centered "⋮" HTML, sized to sit above and centered over a CTA box (they share
150
161
  * the same inline-block, so the dots span exactly the button's width). */
151
162
  function dotsOverCtaHtml(): string {
152
- return `<div style="${MONO} font-size: 20px; font-weight: bold; color: #666; text-align: center; margin: 0 0 16px 0;">⋮</div>`;
163
+ return `<div ${styleClass("dots-over-cta", "dots")}>⋮</div>`;
153
164
  }
154
165
 
155
166
  /**
@@ -164,15 +175,15 @@ function renderChatCta(
164
175
  align: "left" | "right",
165
176
  withDots: boolean,
166
177
  ): { html: string; text: string[] } {
167
- const { html: btnHtml, text: btnText } = ctaButton(cta, "0");
178
+ const { html: btnHtml, text: btnText } = ctaButton(cta, "none");
168
179
  // Dots + button share one inline-block so the dots center over the button's
169
180
  // exact width regardless of label length.
170
- const stack = `<div style="display: inline-block; text-align: left;">${withDots ? dotsOverCtaHtml() : ""}${btnHtml}</div>`;
171
- const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 24px 0;">
181
+ const stack = `<div ${styleClass("cta-stack")}>${withDots ? dotsOverCtaHtml() : ""}${btnHtml}</div>`;
182
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" ${styleClass("chat-row-24")}>
172
183
  <tr>
173
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
174
- <td style="width: 100%; text-align: ${align};">${stack}</td>
175
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
184
+ <td ${styleClass("chat-avatar-left-hidden")}>[c_S]</td>
185
+ <td ${styleClass(`chat-channel-${align}`)}>${stack}</td>
186
+ <td ${styleClass("chat-avatar-right-hidden")}>(•̀_ರ╮)</td>
176
187
  </tr>
177
188
  </table>`;
178
189
 
@@ -204,11 +215,11 @@ function renderChatCta(
204
215
  * case folds the dots into the CTA via `renderChatCta`, centered over the box).
205
216
  */
206
217
  function renderChatDots(): { html: string; text: string[] } {
207
- const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 16px 0;">
218
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" ${styleClass("chat-row-16")}>
208
219
  <tr>
209
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
210
- <td style="width: 100%; text-align: center; ${MONO} font-size: 20px; font-weight: bold; color: #666;">⋮</td>
211
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
220
+ <td ${styleClass("chat-avatar-left-hidden")}>[c_S]</td>
221
+ <td ${styleClass("dots-cell", "dots")}>⋮</td>
222
+ <td ${styleClass("chat-avatar-right-hidden")}>(•̀_ರ╮)</td>
212
223
  </tr>
213
224
  </table>`;
214
225
  // Center the "⋮" over the message box (avatar gutter + box width + borders).
@@ -251,11 +262,11 @@ export function renderChatUnit(items: ChatUnitItem[]): {
251
262
  // A bubble directly above a CTA or continuation dots gets a tighter 16px
252
263
  // gap; else 24px.
253
264
  const next = items[i + 1]?.type;
254
- const margin =
265
+ const row: ChatRow =
255
266
  next === "callToAction" || next === "continuation"
256
- ? "0 0 16px 0"
257
- : "0 0 24px 0";
258
- parts.push(renderBubble(item, margin));
267
+ ? "chat-row-16"
268
+ : "chat-row-24";
269
+ parts.push(renderBubble(item, row));
259
270
  });
260
271
 
261
272
  const width = parts