@company-semantics/contracts 35.1.0 → 37.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.
@@ -138,11 +138,24 @@ export interface Signature {
138
138
  signer?: string;
139
139
  }
140
140
 
141
- /** A leading image. `alt` is required — a text-only channel has nothing else. */
141
+ /**
142
+ * A leading image. `alt` is required — a text-only channel has nothing else.
143
+ *
144
+ * `width`/`height` are the image's intrinsic pixel dimensions. They are optional
145
+ * because a channel that can measure an image itself does not need to be told, and
146
+ * requiring them would put a rendering constraint into channel-agnostic content.
147
+ * But a channel MAY be unable to draw one without them, and the constraint is real
148
+ * even though the surface that first raised it is gone: AMP's `<amp-img>` required
149
+ * explicit dimensions to reserve layout, and the email channel's AMP surface fell
150
+ * back to the `alt` text without them (retired in ADR-CONTRACTS-091). Supplying
151
+ * them is what makes the image drawable everywhere.
152
+ */
142
153
  export interface HeroImage {
143
154
  type: "heroImage";
144
155
  src: string;
145
156
  alt: string;
157
+ width?: number;
158
+ height?: number;
146
159
  }
147
160
 
148
161
  // =============================================================================
@@ -58,17 +58,23 @@ export type { RenderedEmail } from "./renderers/email";
58
58
 
59
59
  export { emailRenderer } from "./renderers/email";
60
60
 
61
+ // The `Slack*` block types this barrel used to export were a hand-written subset
62
+ // of Block Kit and are GONE (ADR-CONTRACTS-090). Slack's own `@slack/types` is
63
+ // the vocabulary now, re-exported here so a consumer need not depend on it
64
+ // directly: `SlackBlock` -> `KnownBlock`, `SlackSectionBlock` -> `SectionBlock`,
65
+ // `SlackMrkdwnText` -> `MrkdwnElement`, `SlackPlainText` -> `PlainTextElement`.
61
66
  export type {
62
- SlackActionsBlock,
63
- SlackBlock,
64
- SlackButtonElement,
65
- SlackContextBlock,
66
- SlackDividerBlock,
67
- SlackImageBlock,
67
+ ActionsBlock,
68
+ ContextBlock,
69
+ DividerBlock,
70
+ HeaderBlock,
71
+ ImageBlock,
72
+ KnownBlock,
73
+ MrkdwnElement,
74
+ PlainTextElement,
75
+ RichTextBlock,
76
+ SectionBlock,
68
77
  SlackMessage,
69
- SlackMrkdwnText,
70
- SlackPlainText,
71
- SlackSectionBlock,
72
78
  } from "./renderers/slack";
73
79
 
74
80
  export { slackRenderer } from "./renderers/slack";
@@ -10,21 +10,81 @@ deleted, what replaced it. That layer offered templates a component per intent
10
10
  here the CONTENT names the intent and this directory is the only thing that knows
11
11
  the markup. The strings are the same strings.
12
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` |
13
+ | module | what it holds |
14
+ | ---------------- | -------------------------------------------------------------------------------------- |
15
+ | `index.ts` | `emailRenderer` + `RenderedEmail`, the channel's natural type |
16
+ | `render.ts` | each `NotificationElement` → its class-only `<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 two surfaces |
20
+ | `styles.ts` | every declaration, as recipes plus `inlineStyles` |
21
+ | `colors.ts` | every colour, by role, in every scheme — and the dark stylesheet |
22
+ | `constants.ts` | typography and layout (`MONO_DECL`, `FONT_SIZE`, `SPACING`, `CTA_PAD`) + `COMPANY_URL` |
23
+ | `escape-html.ts` | `escapeHtml` |
22
24
 
23
25
  `constants.ts` and `escape-html.ts` moved here from the old `src/email/render`
24
26
  before it was deleted. The brand NAME is deliberately not among them: it is
25
27
  `../../context`'s `COMPANY_NAME`, because it is the same answer on every channel
26
28
  and renderers read it from `context.brand`.
27
29
 
30
+ ## Two surfaces, one markup (ADR-CONTRACTS-089, -091)
31
+
32
+ An email is a `multipart/alternative`: `text/plain` and `text/html` are two
33
+ presentations of ONE message, and `RenderedEmail` carries both.
34
+
35
+ `render.ts`, `cta.ts` and `chat.ts` emit CLASS-ONLY markup, and `htmlShell` runs
36
+ `inlineStyles`, which turns recipe classes back into the exact inline styles this
37
+ channel has always shipped.
38
+
39
+ `EmailLine` gains no third field: `../../renderer.ts` warns against exactly that,
40
+ and a surface is a way of SPENDING the lines rather than a thing a line carries.
41
+ The cost is that `EmailLine.html` is an intermediate representation — `render.ts`
42
+ does not read as the bytes it emits, and the snapshot is where the real markup is
43
+ legible.
44
+
45
+ ### There was a third surface, and removing it is the point
46
+
47
+ `text/x-amp-html` shipped in 36.0.0 and was retired in 37.0.0. The class-only split
48
+ above exists BECAUSE of it — AMP forbids inline `style`, the html surface is built
49
+ from it — and the split stays: the registry is now the single source for a recipe,
50
+ and the markup is byte-identical either way (removing AMP changed the snapshot by
51
+ 1965 deletions and **zero** insertions).
52
+
53
+ AMP bought `:hover` in Gmail. It cost Gmail iOS its dark mode, and we did not know
54
+ that when we shipped it: **Gmail iOS inverts the html part and does NOT touch an AMP
55
+ part**, so having one opts those readers out of the only dark scheme they get. That
56
+ is measured, not argued — `company-semantics-backend/docs/dark-mode-probe.md`, and
57
+ it is a fact the public record does not contain. A hover state is worth less than a
58
+ legible email (ADR-CONTRACTS-091).
59
+
60
+ Two things we learned the expensive way, so nobody re-derives them:
61
+
62
+ - `prefers-color-scheme` in `<style amp-custom>` is a hard AMP4EMAIL validator
63
+ ERROR. Real, permanent, frozen since 2023.
64
+ - The `media` ATTRIBUTE on an AMP element takes the same feature and PASSES the
65
+ validator — and Gmail's runtime ignores it entirely, rendering both a dark-gated
66
+ and a light-gated block. Validator-legal, runtime-inert. That door is closed.
67
+
68
+ ## Colour — the thing this channel has and the others do not
69
+
70
+ `colors.ts` is the only place a colour lives, and it is the channel's own rather
71
+ than the layer's. Email hand-authors colour because it is the only channel with
72
+ no client-side semantic vocabulary to lean on: `../slack` says `:warning:` and
73
+ `context` and lets Slack's client theme them (Block Kit exposes no author-set
74
+ colour at all), and `../sms` has none to have. A shared `notifications/colors.ts`
75
+ would look DRY and would put a channel's presentation back inside the
76
+ channel-agnostic layer — the coupling `../../renderer.ts` exists to prevent. A
77
+ channel that needs colour gets its own file next to its own renderer.
78
+
79
+ Colour is named by ROLE, and a scheme owes an answer to every role. Roles are
80
+ meaning, not coincidence: `border`, `bubble`, `meta` and `dots` all answer `#666`
81
+ in light and are still four roles, because a CTA's border and a chat bubble's
82
+ fill have no reason to move together.
83
+
84
+ The channel renders in **one or more schemes** — two today (`light`, `dark`), and
85
+ callers ask `palette(scheme)` rather than importing a concrete palette, so a
86
+ third is an entry in a table rather than an edit to every call site.
87
+
28
88
  ## Spacing — the thing the content model does not carry
29
89
 
30
90
  The old layer passed a `Spacing` per block; `../../kinds` deliberately dropped
@@ -71,8 +131,57 @@ expect to style it properly rather than trust the placeholder.
71
131
  - Values arrive pre-formatted: dates, title-casing and truncation are `compose`'s
72
132
  work. Nothing here re-formats a value.
73
133
  - 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
134
+ before interpolation — INCLUDING `href` and `src`, which reach the markup as
135
+ URLs rather than as text and were interpolated raw until ADR-CONTRACTS-089. A
136
+ URL that can close its attribute can forge another; under `inlineStyles` it
137
+ could also forge a `csr-` class and write any declaration in the registry into
138
+ someone else's element. That is also what makes the regex in `inlineStyles` safe:
139
+ nothing that reaches it can contain an unescaped `"`.
140
+ - The markup is CLASS-ONLY. `cs-` role classes SHIP (the dark stylesheet needs the
141
+ hook); `csr-` recipe classes DO NOT — `inlineStyles` spends and drops them. That
142
+ prefix split is a mechanism, not a convention: it is what lets one markup serve
143
+ two surfaces, and it is why `__tests__/colors.test.ts` needs no filter — it scans
144
+ rendered output, where recipe classes no longer exist.
145
+ - `MONO_DECL` / `FONT_SIZE` / `SPACING` / `CTA_PAD` are this channel's own and are
146
+ NOT exported
76
147
  from the domain barrel: a margin is not vocabulary. They were duplicated from
77
148
  the old `email/render/blocks`'s private copies while both layers existed; that
78
149
  duplication ended with the directory, and these are now the only copies.
150
+ `colors.ts` is unexported for the same reason — a colour is not vocabulary
151
+ either.
152
+ - NO module here states a colour. Every colour comes from `colors.ts` via a role;
153
+ `__tests__/colors.test.ts` fails on a raw hex anywhere else in the directory,
154
+ including one in a comment, because a comment naming a literal goes stale the
155
+ first time the palette is tuned.
156
+ - Light is inline and dark is the `<style>` block, and that is the mechanism rather
157
+ than duplication: one source each for two schemes, both from `colors.ts`. Every
158
+ dark declaration carries `!important` because the inline light colour would
159
+ otherwise win on specificity and dark would silently do nothing.
160
+ - `:hover` is the one exception, because it has no inline form: BOTH schemes state
161
+ it in the stylesheet (`BASE_STYLE` light, `DARK_STYLE` dark, resolved by source
162
+ order). It is opportunistic — Apple Mail honours it, Gmail and classic Outlook do
163
+ not — and needs no fallback, because without it the button simply rests.
164
+ - **`DARK_STYLE` reaches Apple Mail, iOS Mail and Outlook for Mac, and nobody else.**
165
+ No Gmail client honours `prefers-color-scheme` on any surface; Yahoo rewrites the
166
+ query. A Gmail reader's dark mode is whatever their client does to our light
167
+ colours unaided: Gmail iOS inverts the html part, Gmail web does nothing at all.
168
+ Closing the Gmail-web gap means inversion-indifferent authoring HERE — mid-tones,
169
+ no pure `#ffffff`/`#000000` in large fields, solid-fill CTAs — not another surface.
170
+ - Role hooks (`cs-faint`, `cs-bubble`, `cs-cta`, `cs-link`) carry their colours via
171
+ each recipe's `roleDecl`; their only stylesheet rules live in `DARK_STYLE`.
172
+ - A hover hook goes on a CTA ONLY when it has an `href`. An unlinked label is a
173
+ payload (an OTP code), and a hover state on it would promise a click that does
174
+ not exist — the same invariant that keeps it a `<span>` rather than an `<a>`.
175
+ - A linked CTA's whole box is the click target: the padding sits on the `<a>`
176
+ (`display: block`), not the cell. Outlook ignores `display`, so an
177
+ `<!--[if mso]>` block restores the cell padding there and the box degrades to
178
+ today's text click. The padding is one constant shared by the anchor, the cell,
179
+ and the MSO block — they must agree.
180
+ - The `color-scheme` metas and `DARK_STYLE` ship together. The declaration is a
181
+ promise: made alone it stops Apple Mail protecting our colours while leaving
182
+ light text on a dark background — worse than declaring light-only. This channel
183
+ WAS light-only by construction until ADR-CONTRACTS-088; it is not any more.
184
+ - Dark reaches Apple Mail, iOS Mail and Outlook for Mac. Gmail and Outlook
185
+ force-invert and are unreachable — the dark path is additive, and a client that
186
+ strips `<style>` keeps the inline light colours, which is the status quo rather
187
+ than a degradation.
@@ -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
+ });
@@ -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, 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 every recipe as an inline style, from one source", () => {
104
+ // `ruleOf` — the same recipe as a CSS rule — went with the AMP surface
105
+ // (ADR-CONTRACTS-091). It had no other caller, and a rule nobody states is not
106
+ // a shape this registry needs to keep proving it can take.
107
+ for (const name of STYLE_NAMES) {
108
+ expect(inlineOf(name).endsWith(";")).toBe(true);
109
+ }
110
+ });
111
+ });