@company-semantics/contracts 35.0.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.
Files changed (36) hide show
  1. package/package.json +7 -2
  2. package/src/notifications/README.md +21 -19
  3. package/src/notifications/__tests__/README.md +42 -47
  4. package/src/notifications/__tests__/__snapshots__/README.md +15 -8
  5. package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +31 -0
  6. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +2580 -215
  7. package/src/notifications/__tests__/context.test.ts +5 -5
  8. package/src/notifications/__tests__/definition.test.ts +2 -2
  9. package/src/notifications/__tests__/fixtures.ts +226 -0
  10. package/src/notifications/__tests__/kinds.test.ts +2 -3
  11. package/src/notifications/__tests__/registry.test.ts +27 -27
  12. package/src/notifications/__tests__/render-snapshot.test.ts +10 -6
  13. package/src/notifications/content.ts +13 -1
  14. package/src/notifications/index.ts +15 -9
  15. package/src/notifications/kinds/README.md +2 -2
  16. package/src/notifications/render.ts +6 -6
  17. package/src/notifications/renderers/email/README.md +132 -17
  18. package/src/notifications/renderers/email/__tests__/README.md +19 -16
  19. package/src/notifications/renderers/email/__tests__/amp.test.ts +217 -0
  20. package/src/notifications/renderers/email/__tests__/colors.test.ts +227 -0
  21. package/src/notifications/renderers/email/__tests__/render.test.ts +130 -36
  22. package/src/notifications/renderers/email/__tests__/styles.test.ts +111 -0
  23. package/src/notifications/renderers/email/chat.ts +44 -32
  24. package/src/notifications/renderers/email/colors.ts +342 -0
  25. package/src/notifications/renderers/email/constants.ts +27 -3
  26. package/src/notifications/renderers/email/cta.ts +60 -10
  27. package/src/notifications/renderers/email/index.ts +23 -8
  28. package/src/notifications/renderers/email/render.ts +31 -15
  29. package/src/notifications/renderers/email/shells.ts +115 -11
  30. package/src/notifications/renderers/email/styles.ts +361 -0
  31. package/src/notifications/renderers/slack/README.md +66 -25
  32. package/src/notifications/renderers/slack/__tests__/index.test.ts +249 -41
  33. package/src/notifications/renderers/slack/index.ts +192 -107
  34. package/src/notifications/text.ts +2 -2
  35. package/src/notifications/__tests__/output-parity.golden.ts +0 -363
  36. package/src/notifications/__tests__/output-parity.test.ts +0 -122
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The rules that keep `../colors.ts` the only place colour lives
3
+ * (ADR-CONTRACTS-088).
4
+ *
5
+ * The markup is not asserted here — `../../../__tests__/render-snapshot.test.ts`
6
+ * locks that. These are the claims a snapshot cannot make, because a snapshot
7
+ * records what the output IS and every one of these is about what it must never
8
+ * become:
9
+ *
10
+ * - A dark stylesheet is invisible to the snapshot's reviewer in the sense that
11
+ * matters: you can read the `<style>` block in a diff and still not notice that
12
+ * a role is missing from it, because the light bytes look perfect either way.
13
+ * Dark mode fails silently. These tests are the alarm.
14
+ * - `DARK_STYLE` is a hand-written template, deliberately (it reads as the CSS it
15
+ * is). The cost of a template over generated output is that it can forget a
16
+ * role; this is where that cost is paid back.
17
+ */
18
+
19
+ import { readFileSync, readdirSync } from "node:fs";
20
+ import { dirname, join } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ import { describe, expect, it } from "vitest";
24
+
25
+ import type {
26
+ NotificationContent,
27
+ NotificationElement,
28
+ } from "../../../content";
29
+ import type { RenderContext } from "../../../context";
30
+ import {
31
+ BASE_STYLE,
32
+ CLASS_ROLES,
33
+ DARK_STYLE,
34
+ HOVER_ROLES,
35
+ palette,
36
+ ROLES,
37
+ roleClass,
38
+ } from "../colors";
39
+ import { emailRenderer } from "../index";
40
+
41
+ const CONTEXT: RenderContext = {
42
+ brand: { name: "Company Semantics", copyrightYear: 2026 },
43
+ };
44
+
45
+ /** The class name inside a role's attribute — `cs-meta` from `class="cs-meta"`. */
46
+ function classNameOf(role: (typeof ROLES)[number]): string {
47
+ const match = roleClass(role).match(/class="([^"]+)"/);
48
+ if (!match) throw new Error(`roleClass(${role}) is not a class attribute`);
49
+ return match[1];
50
+ }
51
+
52
+ /**
53
+ * One notification exercising every colour-bearing element. The chat unit carries
54
+ * both continuation shapes on purpose: dots followed by a CTA fold into it, and
55
+ * dots followed by nothing render standalone — two different emitters, two
56
+ * chances to forget a class.
57
+ */
58
+ const EVERY_COLOURED_ELEMENT: NotificationContent = {
59
+ metadata: { kind: "chat.shared", title: "Colour coverage" },
60
+ sections: [
61
+ {
62
+ elements: [
63
+ { type: "warning" },
64
+ { type: "divider" },
65
+ { type: "callToAction", label: "Open", href: "https://example.com/a" },
66
+ {
67
+ type: "chatUnit",
68
+ items: [
69
+ { type: "message", role: "user", text: "Hello", from: "Sam Chen" },
70
+ { type: "continuation" },
71
+ {
72
+ type: "callToAction",
73
+ label: "Reply",
74
+ href: "https://example.com/b",
75
+ },
76
+ { type: "message", role: "assistant", text: "Hi back" },
77
+ { type: "continuation" },
78
+ ],
79
+ },
80
+ { type: "signature" },
81
+ ],
82
+ },
83
+ ],
84
+ };
85
+
86
+ describe("email colours", () => {
87
+ it("gives every role a dark answer that reaches the stylesheet", () => {
88
+ const dark = palette("dark");
89
+ for (const role of ROLES) {
90
+ expect(DARK_STYLE, `role "${role}" is absent from DARK_STYLE`).toContain(
91
+ dark[role],
92
+ );
93
+ }
94
+ });
95
+
96
+ it("writes a rule for every class-bearing role", () => {
97
+ for (const role of CLASS_ROLES) {
98
+ // `:hover` roles are selected as `.cs-x:hover {`, resting ones as `.cs-x {`.
99
+ const selector = new RegExp(`\\.${classNameOf(role)}(:[a-z-]+)? \\{`);
100
+ expect(DARK_STYLE, `role "${role}" has a class but no dark rule`).toMatch(
101
+ selector,
102
+ );
103
+ }
104
+ });
105
+
106
+ it("states every hover role in BOTH schemes, since :hover cannot be inline", () => {
107
+ // Every other role is inline-light + dark override. A hover role has no inline
108
+ // form, so a light answer that never reaches BASE_STYLE is simply lost — and
109
+ // nothing else in this file would notice.
110
+ for (const role of HOVER_ROLES) {
111
+ const selector = new RegExp(`\\.${classNameOf(role)}:hover \\{`);
112
+ expect(BASE_STYLE, `hover role "${role}" has no light rule`).toMatch(
113
+ selector,
114
+ );
115
+ expect(BASE_STYLE, `hover role "${role}" light value`).toContain(
116
+ palette("light")[role],
117
+ );
118
+ expect(DARK_STYLE, `hover role "${role}" has no dark rule`).toMatch(
119
+ selector,
120
+ );
121
+ }
122
+ });
123
+
124
+ it("marks every declaration !important, in both blocks", () => {
125
+ // Load-bearing, not defensive: the light colour is inline, inline beats a
126
+ // stylesheet on specificity, and a rule that loses that fight does nothing
127
+ // at all while looking entirely correct.
128
+ const rules = `${BASE_STYLE}\n${DARK_STYLE}`.split("\n").filter((line) => {
129
+ const start = line.trimStart();
130
+ return start.startsWith(".") || start.startsWith("body");
131
+ });
132
+ // Guards the filter above: a reformat that matches no lines would turn the
133
+ // loop below into a vacuous pass. `body` is the +1; BASE_STYLE contributes
134
+ // one line per hover role.
135
+ expect(rules).toHaveLength(CLASS_ROLES.length + 1 + HOVER_ROLES.length);
136
+
137
+ for (const rule of rules) {
138
+ const body = rule.slice(rule.indexOf("{") + 1, rule.lastIndexOf("}"));
139
+ const declarations = body
140
+ .split(";")
141
+ .map((d) => d.trim())
142
+ .filter(Boolean);
143
+ expect(declarations.length).toBeGreaterThan(0);
144
+ for (const declaration of declarations) {
145
+ expect(
146
+ declaration,
147
+ `"${declaration}" would lose to the inline style`,
148
+ ).toContain("!important");
149
+ }
150
+ }
151
+ });
152
+
153
+ it("styles every class the renderer actually emits", () => {
154
+ // The end-to-end direction: the tests above start from the palette, this one
155
+ // starts from the output, so a class hook added to a tag without a matching
156
+ // rule is caught from the side the palette cannot see.
157
+ const { html } = emailRenderer.render(EVERY_COLOURED_ELEMENT, CONTEXT);
158
+ // One attribute can carry several classes (`class="cs-cta cs-cta-hover"`).
159
+ const emitted = new Set(
160
+ [...html.matchAll(/class="([^"]+)"/g)].flatMap((match) =>
161
+ match[1].split(/\s+/),
162
+ ),
163
+ );
164
+ expect(emitted.size).toBeGreaterThan(0);
165
+ const stylesheet = `${BASE_STYLE}\n${DARK_STYLE}`;
166
+ for (const className of emitted) {
167
+ expect(stylesheet, `${className} is emitted but never styled`).toMatch(
168
+ new RegExp(`\\.${className}(:[a-z-]+)? \\{`),
169
+ );
170
+ }
171
+ });
172
+
173
+ it("puts the hover hook on a CTA that links, and never on one that does not", () => {
174
+ // The invariant `./cta.ts` already carried — an unlinked label is a payload,
175
+ // not a button — extended to hover. Lighting up an OTP code under the pointer
176
+ // promises a click that does not exist.
177
+ const hoverClass = classNameOf("ctaHover");
178
+ // The classes the markup APPLIES, not every mention in the document — the
179
+ // stylesheet names `.cs-cta-hover` too, and matching that would pass whether
180
+ // or not any element wears it.
181
+ const applied = (cta: NotificationElement) => {
182
+ const { html } = emailRenderer.render(
183
+ {
184
+ metadata: { kind: "auth.otp", title: "t" },
185
+ sections: [{ elements: [cta] }],
186
+ },
187
+ CONTEXT,
188
+ );
189
+ return [...html.matchAll(/class="([^"]+)"/g)].flatMap((match) =>
190
+ match[1].split(/\s+/),
191
+ );
192
+ };
193
+
194
+ const linked = applied({
195
+ type: "callToAction",
196
+ label: "Open",
197
+ href: "https://example.com/a",
198
+ });
199
+ const payload = applied({ type: "callToAction", label: "123456" });
200
+
201
+ expect(linked).toContain(hoverClass);
202
+ expect(payload).not.toContain(hoverClass);
203
+ // Both still wear the resting edge.
204
+ expect(payload).toContain(classNameOf("cta"));
205
+ });
206
+
207
+ it("leaves no raw colour anywhere else in the channel", () => {
208
+ // What stops `style="color:#888"` reappearing in chat.ts in six months. A
209
+ // test rather than a CI guard: guards live in company-semantics-ci and adding
210
+ // one is cross-repo plus its own ADR — revisit if this earns it.
211
+ const dir = dirname(dirname(fileURLToPath(import.meta.url)));
212
+ const hex = /#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/;
213
+
214
+ const sources = readdirSync(dir).filter(
215
+ (file) => file.endsWith(".ts") && file !== "colors.ts",
216
+ );
217
+ expect(sources.length).toBeGreaterThan(0);
218
+
219
+ for (const file of sources) {
220
+ const found = readFileSync(join(dir, file), "utf8").match(hex);
221
+ expect(
222
+ found?.[0],
223
+ `${file} states ${found?.[0]} directly — give it a role in colors.ts`,
224
+ ).toBeUndefined();
225
+ }
226
+ });
227
+ });
@@ -1,48 +1,46 @@
1
1
  /**
2
- * The email renderer's correctness proof (ADR-CONTRACTS-086).
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.
3
7
  *
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.
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.
15
14
  *
16
15
  * 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.
16
+ * year comes from `RenderContext`, so the fixtures' clock is just a number.
18
17
  */
19
18
 
20
19
  import { describe, expect, it } from "vitest";
21
20
 
22
21
  import type {
23
22
  NotificationContent,
23
+ NotificationElement,
24
24
  NotificationElementType,
25
25
  } from "../../../content";
26
26
  import type { RenderContext } from "../../../context";
27
27
  import { NOTIFICATION_DEFINITIONS } from "../../../registry";
28
28
  import {
29
- OUTPUT_PARITY_CLOCK,
30
- OUTPUT_PARITY_FIXTURES,
31
- OUTPUT_PARITY_GOLDEN,
29
+ FIXTURE_CLOCK,
30
+ NOTIFICATION_FIXTURES,
32
31
  fixtureKey,
33
- } from "../../../__tests__/output-parity.golden";
32
+ } from "../../../__tests__/fixtures";
34
33
  import { emailRenderer } from "../index";
35
34
 
36
35
  /**
37
- * The golden's context, stated rather than ticked. The old layer reached for
36
+ * The fixtures' context, stated rather than ticked. The old layer reached for
38
37
  * `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.
38
+ * freeze the clock; here the year is simply a value.
41
39
  */
42
- const GOLDEN_CONTEXT: RenderContext = {
40
+ const FIXTURE_CONTEXT: RenderContext = {
43
41
  brand: {
44
42
  name: "Company Semantics",
45
- copyrightYear: new Date(OUTPUT_PARITY_CLOCK).getUTCFullYear(),
43
+ copyrightYear: new Date(FIXTURE_CLOCK).getUTCFullYear(),
46
44
  },
47
45
  };
48
46
 
@@ -77,7 +75,7 @@ describe("emailRenderer", () => {
77
75
  metadata: { kind: "org.invite", title: "Join Acme" },
78
76
  sections: [{ elements: [{ type: "body", text: "Hello." }] }],
79
77
  };
80
- expect(emailRenderer.render(content, GOLDEN_CONTEXT).subject).toBe(
78
+ expect(emailRenderer.render(content, FIXTURE_CONTEXT).subject).toBe(
81
79
  "Join Acme",
82
80
  );
83
81
  });
@@ -100,7 +98,7 @@ describe("emailRenderer", () => {
100
98
  metadata: { kind: "org.invite", title: "t" },
101
99
  sections: [{ elements: [{ type: "signature", signer: "Grace" }] }],
102
100
  };
103
- expect(emailRenderer.render(content, GOLDEN_CONTEXT).text).toContain(
101
+ expect(emailRenderer.render(content, FIXTURE_CONTEXT).text).toContain(
104
102
  "• Grace",
105
103
  );
106
104
  });
@@ -126,7 +124,7 @@ describe("emailRenderer", () => {
126
124
  },
127
125
  ],
128
126
  };
129
- const { text } = emailRenderer.render(content, GOLDEN_CONTEXT);
127
+ const { text } = emailRenderer.render(content, FIXTURE_CONTEXT);
130
128
  expect(text).toContain("Status: Valid");
131
129
  expect(text).toContain("Request details:");
132
130
  expect(text).toContain("IP address: 203.0.113.1");
@@ -141,11 +139,96 @@ describe("emailRenderer", () => {
141
139
  },
142
140
  ],
143
141
  };
144
- const { html } = emailRenderer.render(content, GOLDEN_CONTEXT);
142
+ const { html } = emailRenderer.render(content, FIXTURE_CONTEXT);
145
143
  expect(html).toContain("&lt;script&gt;");
146
144
  expect(html).not.toContain("<script>");
147
145
  });
148
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
+
149
232
  it("is pure — same inputs, same bytes", () => {
150
233
  const content = NOTIFICATION_DEFINITIONS["security.alert"].compose(
151
234
  {
@@ -153,27 +236,38 @@ describe("emailRenderer", () => {
153
236
  details: "5 codes in 2 minutes",
154
237
  timestamp: "2026-07-15T00:00:00.000Z",
155
238
  },
156
- GOLDEN_CONTEXT,
239
+ FIXTURE_CONTEXT,
157
240
  );
158
- expect(emailRenderer.render(content, GOLDEN_CONTEXT)).toEqual(
159
- emailRenderer.render(content, GOLDEN_CONTEXT),
241
+ expect(emailRenderer.render(content, FIXTURE_CONTEXT)).toEqual(
242
+ emailRenderer.render(content, FIXTURE_CONTEXT),
160
243
  );
161
244
  });
162
245
 
163
- describe("output parity with the email layer at 32.0.0", () => {
164
- for (const fixture of OUTPUT_PARITY_FIXTURES) {
246
+ describe("renders every fixture", () => {
247
+ // Was a byte-for-byte assertion against the 32.0.0 golden until
248
+ // ADR-CONTRACTS-087 retired it. The markup itself is locked by
249
+ // `../../../__tests__/render-snapshot.test.ts`; what is worth proving HERE
250
+ // is narrower and survives a redesign — that every kind in the registry
251
+ // composes and renders through this channel at all, on both surfaces. A kind
252
+ // whose `compose` reaches for a field its payload lacks fails here, at the
253
+ // renderer, rather than in whichever consumer sends it first.
254
+ for (const fixture of NOTIFICATION_FIXTURES) {
165
255
  const key = fixtureKey(fixture.kind, fixture.name);
166
- it(`${key} renders byte-for-byte`, () => {
256
+ it(`${key}`, () => {
167
257
  const definition = NOTIFICATION_DEFINITIONS[fixture.kind];
168
258
  // `payload as never`: the fixture call sites are type-checked in the
169
- // golden module; this only bridges compose's per-kind payload.
259
+ // fixtures module; this only bridges compose's per-kind payload.
170
260
  const content = definition.compose(
171
261
  fixture.payload as never,
172
- GOLDEN_CONTEXT,
262
+ FIXTURE_CONTEXT,
173
263
  );
174
- expect(emailRenderer.render(content, GOLDEN_CONTEXT)).toEqual(
175
- OUTPUT_PARITY_GOLDEN[key],
264
+ const { subject, text, html } = emailRenderer.render(
265
+ content,
266
+ FIXTURE_CONTEXT,
176
267
  );
268
+ expect(subject).toBeTruthy();
269
+ expect(text).toBeTruthy();
270
+ expect(html).toContain("<!DOCTYPE html>");
177
271
  });
178
272
  }
179
273
  });
@@ -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
+ });