@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.
@@ -138,11 +138,23 @@ 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: AMP's `<amp-img>` requires
148
+ * explicit dimensions to reserve layout before the image loads, so the email
149
+ * channel's AMP surface degrades to the `alt` text when they are absent
150
+ * (ADR-CONTRACTS-089). Supplying them is what makes the image drawable everywhere.
151
+ */
142
152
  export interface HeroImage {
143
153
  type: "heroImage";
144
154
  src: string;
145
155
  alt: string;
156
+ width?: number;
157
+ height?: number;
146
158
  }
147
159
 
148
160
  // =============================================================================
@@ -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,76 @@ 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` / `ampShell` / `textShell` — what makes one email three surfaces |
20
+ | `styles.ts` | every declaration, as recipes plus `inlineStyles` and `ampify` |
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
+ ## Three surfaces, one markup (ADR-CONTRACTS-089)
31
+
32
+ An email is a `multipart/alternative`: `text/plain`, `text/html` and
33
+ `text/x-amp-html` are three presentations of ONE message, and `RenderedEmail`
34
+ carries all three. AMP is not a fourth channel — a recipient gets one email.
35
+
36
+ The catch is that the two HTML-ish surfaces disagree about styling at the root.
37
+ AMP4EMAIL forbids inline `style` attributes and `!important`; the html surface is
38
+ built from both, deliberately (see Colour below). So **the markup belongs to
39
+ neither**: `render.ts`, `cta.ts` and `chat.ts` emit CLASS-ONLY markup, and each
40
+ shell spends `styles.ts` its own way —
41
+
42
+ - `htmlShell` runs `inlineStyles`, which turns recipe classes back into the exact
43
+ inline styles this channel has always shipped;
44
+ - `ampShell` runs `ampify` (the `<img>` → `<amp-img>` swap) and states the same
45
+ recipes as `<style amp-custom>`.
46
+
47
+ `EmailLine` gains no third field: `../../renderer.ts` warns against exactly that,
48
+ and a surface is a way of SPENDING the lines rather than a thing a line carries.
49
+ The cost is that `EmailLine.html` is now an intermediate representation —
50
+ `render.ts` no longer reads as the bytes it emits, and the snapshot is where the
51
+ real markup is legible.
52
+
53
+ **What AMP is for:** `:hover`. ADR-CONTRACTS-088 bought a hover state that Gmail
54
+ does not honour, and Gmail is most opens. AMP is where that rule reaches people.
55
+
56
+ **What AMP does not have:** dark. AMP4EMAIL disallows the `prefers-color-scheme`
57
+ media feature outright. It costs nothing — see Colour.
58
+
59
+ `pnpm validate:amp` runs the real AMP validator. `__tests__/amp.test.ts` asserts
60
+ the constraints we KNOW on every run, which is not the same claim: it passed once
61
+ while every fixture was invalid.
62
+
63
+ ## Colour — the thing this channel has and the others do not
64
+
65
+ `colors.ts` is the only place a colour lives, and it is the channel's own rather
66
+ than the layer's. Email hand-authors colour because it is the only channel with
67
+ no client-side semantic vocabulary to lean on: `../slack` says `:warning:` and
68
+ `context` and lets Slack's client theme them (Block Kit exposes no author-set
69
+ colour at all), and `../sms` has none to have. A shared `notifications/colors.ts`
70
+ would look DRY and would put a channel's presentation back inside the
71
+ channel-agnostic layer — the coupling `../../renderer.ts` exists to prevent. A
72
+ channel that needs colour gets its own file next to its own renderer.
73
+
74
+ Colour is named by ROLE, and a scheme owes an answer to every role. Roles are
75
+ meaning, not coincidence: `border`, `bubble`, `meta` and `dots` all answer `#666`
76
+ in light and are still four roles, because a CTA's border and a chat bubble's
77
+ fill have no reason to move together.
78
+
79
+ The channel renders in **one or more schemes** — two today (`light`, `dark`), and
80
+ callers ask `palette(scheme)` rather than importing a concrete palette, so a
81
+ third is an entry in a table rather than an edit to every call site.
82
+
28
83
  ## Spacing — the thing the content model does not carry
29
84
 
30
85
  The old layer passed a `Spacing` per block; `../../kinds` deliberately dropped
@@ -71,8 +126,64 @@ expect to style it properly rather than trust the placeholder.
71
126
  - Values arrive pre-formatted: dates, title-casing and truncation are `compose`'s
72
127
  work. Nothing here re-formats a value.
73
128
  - 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
129
+ before interpolation — INCLUDING `href` and `src`, which reach the markup as
130
+ URLs rather than as text and were interpolated raw until ADR-CONTRACTS-089. A
131
+ URL that can close its attribute can forge another; under `inlineStyles` it
132
+ could also forge a `csr-` class and write any declaration in the registry into
133
+ someone else's element. That is also what makes the regex in `inlineStyles` safe:
134
+ nothing that reaches it can contain an unescaped `"`.
135
+ - The markup is CLASS-ONLY. `cs-` role classes SHIP (the dark stylesheet needs the
136
+ hook); `csr-` recipe classes DO NOT — `inlineStyles` spends and drops them. That
137
+ prefix split is a mechanism, not a convention: it is what lets one markup serve
138
+ two surfaces, and it is why `__tests__/colors.test.ts` needs no filter — it scans
139
+ rendered output, where recipe classes no longer exist.
140
+ - `MONO_DECL` / `FONT_SIZE` / `SPACING` / `CTA_PAD` are this channel's own and are
141
+ NOT exported
76
142
  from the domain barrel: a margin is not vocabulary. They were duplicated from
77
143
  the old `email/render/blocks`'s private copies while both layers existed; that
78
144
  duplication ended with the directory, and these are now the only copies.
145
+ `colors.ts` is unexported for the same reason — a colour is not vocabulary
146
+ either.
147
+ - NO module here states a colour. Every colour comes from `colors.ts` via a role;
148
+ `__tests__/colors.test.ts` fails on a raw hex anywhere else in the directory,
149
+ including one in a comment, because a comment naming a literal goes stale the
150
+ first time the palette is tuned.
151
+ - On the HTML surface, light is inline and dark is the `<style>` block, and that is
152
+ the mechanism rather than duplication: one source each for two schemes, both from
153
+ `colors.ts`. Every dark declaration carries `!important` because the inline light
154
+ colour would otherwise win on specificity and dark would silently do nothing. On
155
+ the AMP surface there is no inline style, so the recipes carry light and source
156
+ order alone would resolve a cascade — which is moot, because AMP has no dark.
157
+ - `:hover` is the one exception, because it has no inline form: BOTH schemes state
158
+ it in the stylesheet (`BASE_STYLE` light, `DARK_STYLE` dark, resolved by source
159
+ order). On the HTML surface it is opportunistic — Apple Mail honours it, Gmail and
160
+ classic Outlook do not — and needs no fallback, because without it the button
161
+ simply rests. It is also the ONE rule the AMP surface shares, via
162
+ `hoverStyle(false)`; AMP forbids `!important`, which is why that template takes a
163
+ parameter and `DARK_STYLE` does not.
164
+ - The AMP surface has NO dark scheme, and loses nothing by it. AMP4EMAIL disallows
165
+ the `prefers-color-scheme` media feature (checked against the AMP validator, with
166
+ and without `data-css-strict`), so it cannot. The audiences are disjoint: dark
167
+ reaches Apple Mail, iOS Mail and Outlook for Mac; AMP reaches Gmail, Yahoo and
168
+ Mail.ru — the force-inverting clients that never received our dark colours anyway.
169
+ AMP swaps a scheme its readers never got for a hover state they never got.
170
+ - Role hooks ride the AMP surface unstyled (`cs-faint`, `cs-bubble`, `cs-cta`,
171
+ `cs-link`), because their only rules live in `DARK_STYLE`. Their colours arrive
172
+ via each recipe's `roleDecl`, so this is dead bytes rather than a bug — the price
173
+ of one markup. `.cs-cta-hover` is the exception: it is the feature.
174
+ - A hover hook goes on a CTA ONLY when it has an `href`. An unlinked label is a
175
+ payload (an OTP code), and a hover state on it would promise a click that does
176
+ not exist — the same invariant that keeps it a `<span>` rather than an `<a>`.
177
+ - A linked CTA's whole box is the click target: the padding sits on the `<a>`
178
+ (`display: block`), not the cell. Outlook ignores `display`, so an
179
+ `<!--[if mso]>` block restores the cell padding there and the box degrades to
180
+ today's text click. The padding is one constant shared by the anchor, the cell,
181
+ and the MSO block — they must agree.
182
+ - The `color-scheme` metas and `DARK_STYLE` ship together. The declaration is a
183
+ promise: made alone it stops Apple Mail protecting our colours while leaving
184
+ light text on a dark background — worse than declaring light-only. This channel
185
+ WAS light-only by construction until ADR-CONTRACTS-088; it is not any more.
186
+ - Dark reaches Apple Mail, iOS Mail and Outlook for Mac. Gmail and Outlook
187
+ force-invert and are unreachable — the dark path is additive, and a client that
188
+ strips `<style>` keeps the inline light colours, which is the status quo rather
189
+ than a degradation.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The AMP surface's constraints (ADR-CONTRACTS-089).
3
+ *
4
+ * **What this file can and cannot claim.** AMP validity is defined by the AMP
5
+ * validator, and the validator fetches itself over the network — so it cannot live
6
+ * in this package's pure suite and runs behind `pnpm validate:amp` instead. What is
7
+ * asserted here is narrower and honest: *the constraints we know*, checked over
8
+ * every fixture. A green run here means "we did not do any of the things we know
9
+ * AMP forbids", not "this is valid AMP".
10
+ *
11
+ * The constraints are structural rather than cosmetic. Each one is a thing the html
12
+ * surface does deliberately and this surface must not do at all — which is why the
13
+ * markup belongs to neither and both are derived from `../styles.ts`.
14
+ */
15
+
16
+ import { describe, expect, it } from "vitest";
17
+
18
+ import { hoverStyle } from "../colors";
19
+ import { NOTIFICATION_FIXTURES, fixtureKey } from "../../../__tests__/fixtures";
20
+ import type { RenderContext } from "../../../context";
21
+ import { NOTIFICATION_DEFINITIONS } from "../../../registry";
22
+ import { emailRenderer } from "../index";
23
+ import { ampify } from "../styles";
24
+
25
+ const CONTEXT: RenderContext = {
26
+ brand: { name: "Company Semantics", copyrightYear: 2026 },
27
+ };
28
+
29
+ /** Gmail's `amp-custom` ceiling. */
30
+ const AMP_CUSTOM_LIMIT = 75_000;
31
+
32
+ /** Every fixture, rendered once. */
33
+ const RENDERED = NOTIFICATION_FIXTURES.map((fixture) => {
34
+ const definition = NOTIFICATION_DEFINITIONS[fixture.kind];
35
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
+ const content = definition.compose(fixture.payload as any, CONTEXT);
37
+ return {
38
+ key: fixtureKey(fixture.kind, fixture.name),
39
+ ...emailRenderer.render(content, CONTEXT),
40
+ };
41
+ });
42
+
43
+ /** The `<style amp-custom>` block's contents. */
44
+ function ampCustom(amp: string): string {
45
+ const open = "<style amp-custom>";
46
+ const start = amp.indexOf(open);
47
+ return amp.slice(start + open.length, amp.indexOf("</style></head>"));
48
+ }
49
+
50
+ /** Everything a reader would actually read, tags removed. */
51
+ function visibleText(html: string): string {
52
+ return html
53
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/g, "")
54
+ .replace(/<script[\s\S]*?<\/script>/g, "")
55
+ .replace(/<!--[\s\S]*?-->/g, "")
56
+ .replace(/<[^>]+>/g, " ")
57
+ .replace(/\s+/g, " ")
58
+ .trim();
59
+ }
60
+
61
+ describe("the AMP surface", () => {
62
+ it("emits every fixture", () => {
63
+ // Guards every loop below: a fixture list that silently emptied would turn
64
+ // each of them into a vacuous pass.
65
+ expect(RENDERED.length).toBeGreaterThan(0);
66
+ });
67
+
68
+ describe.each(RENDERED)("$key", ({ amp }) => {
69
+ it("declares itself AMP, with the runtime and the boilerplate", () => {
70
+ expect(amp.startsWith("<!DOCTYPE html>\n<html amp4email")).toBe(true);
71
+ expect(amp).toContain(
72
+ '<script async src="https://cdn.ampproject.org/v0.js"></script>',
73
+ );
74
+ // The boilerplate hides the body until the runtime unhides it — which is
75
+ // why an AMP document that cannot run scripts renders blank rather than
76
+ // unstyled.
77
+ expect(amp).toContain(
78
+ "<style amp4email-boilerplate>body{visibility:hidden}</style>",
79
+ );
80
+ });
81
+
82
+ it("carries no inline style — the constraint the whole split exists for", () => {
83
+ expect(amp).not.toContain('style="');
84
+ });
85
+
86
+ it("carries no !important, which amp-custom forbids", () => {
87
+ expect(ampCustom(amp)).not.toContain("!important");
88
+ });
89
+
90
+ it("carries no conditional comment", () => {
91
+ // `MSO_CTA_STYLE` is `htmlShell`'s business. It lives in that shell's
92
+ // `<head>`, never in an `EmailLine`, so this holds by construction rather
93
+ // than by stripping.
94
+ expect(amp).not.toContain("<!--[if");
95
+ });
96
+
97
+ it("has exactly one amp-custom stylesheet, in the head", () => {
98
+ expect(amp.match(/<style amp-custom>/g)).toHaveLength(1);
99
+ expect(amp.indexOf("<style amp-custom>")).toBeLessThan(
100
+ amp.indexOf("</head>"),
101
+ );
102
+ });
103
+
104
+ it("fits Gmail's amp-custom budget", () => {
105
+ expect(ampCustom(amp).length).toBeLessThan(AMP_CUSTOM_LIMIT);
106
+ });
107
+
108
+ it("draws images as amp-img, never img", () => {
109
+ expect(amp).not.toMatch(/<img\s/);
110
+ });
111
+
112
+ it("styles every recipe class it applies", () => {
113
+ // The end-to-end direction: start from the output, so a recipe class that
114
+ // reaches the markup with no rule behind it is caught. On this surface a
115
+ // recipe is the ONLY thing carrying an element's appearance — `htmlShell`
116
+ // spends them inline and drops them; here they must all land as rules, or
117
+ // the element renders naked.
118
+ //
119
+ // Role hooks (`cs-`) are deliberately NOT required to have one. They ride
120
+ // along because the markup is shared with the html surface, where
121
+ // `DARK_STYLE` needs them — and dark is exactly what this surface cannot
122
+ // have. So `cs-faint`/`cs-bubble`/`cs-cta`/`cs-link` are inert here: their
123
+ // colours already arrived via their recipe's `roleDecl`. That is the price
124
+ // of one markup, and it is bytes rather than correctness. The one role rule
125
+ // this surface DOES carry is asserted separately below — it is the feature.
126
+ const body = amp.slice(amp.indexOf("<body"));
127
+ const applied = new Set(
128
+ [...body.matchAll(/class="([^"]+)"/g)].flatMap((m) =>
129
+ m[1].split(/\s+/),
130
+ ),
131
+ );
132
+ const recipes = [...applied].filter((c) => c.startsWith("csr-"));
133
+ expect(recipes.length).toBeGreaterThan(0);
134
+ const sheet = ampCustom(amp);
135
+ for (const className of recipes) {
136
+ expect(sheet, `${className} is applied but never styled`).toMatch(
137
+ new RegExp(`\\.${className} \\{`),
138
+ );
139
+ }
140
+ });
141
+ });
142
+
143
+ it("says exactly what the html surface says", () => {
144
+ // The claim `EmailLine` exists to make, now across three surfaces instead of
145
+ // two: the parts of a `multipart/alternative` are ALTERNATIVES, so a client
146
+ // choosing between them must not change what the notification said. This is
147
+ // what a second hand-authored AMP template could never promise.
148
+ for (const { key, html, amp } of RENDERED) {
149
+ expect(visibleText(amp), `${key} says something else in AMP`).toBe(
150
+ visibleText(html),
151
+ );
152
+ }
153
+ });
154
+
155
+ it("states the hover rule without !important, and the html surface with", () => {
156
+ // The one rule both surfaces carry, and the only thing they disagree on: the
157
+ // html surface must beat an inline style, this one has none to beat and
158
+ // forbids the keyword. One template, one parameter; see `../colors.ts`.
159
+ expect(hoverStyle(true)).toContain("!important");
160
+ expect(hoverStyle(false)).not.toContain("!important");
161
+ // The same rule, not merely two rules that both exist.
162
+ expect(hoverStyle(true).replaceAll(" !important", "")).toBe(
163
+ hoverStyle(false),
164
+ );
165
+ });
166
+
167
+ it("carries the hover rule — the reason this surface exists at all", () => {
168
+ // ADR-CONTRACTS-088 bought a `:hover` state that Gmail does not honour, and
169
+ // Gmail is most opens. This surface is where that rule finally reaches a
170
+ // recipient, so its presence is the feature, not an implementation detail.
171
+ const { amp } = RENDERED[0];
172
+ expect(ampCustom(amp)).toContain(".cs-cta-hover:hover");
173
+ });
174
+
175
+ it("carries no dark scheme, because AMP disallows the media feature", () => {
176
+ // NOT an omission to fix later: `prefers-color-scheme` is a disallowed media
177
+ // feature in amp-custom (verified against the AMP validator, with and without
178
+ // `data-css-strict`), so this surface cannot have dark in any form. It costs
179
+ // nothing — the dark block's clients (Apple Mail, iOS Mail, Outlook for Mac)
180
+ // and this surface's clients (Gmail, Yahoo, Mail.ru) are disjoint sets, and
181
+ // the latter force-invert rather than read a stylesheet.
182
+ //
183
+ // This test exists so that "add dark mode to AMP" fails here with the reason,
184
+ // rather than in `pnpm validate:amp` with a CSS syntax error.
185
+ for (const { key, amp } of RENDERED) {
186
+ expect(amp, `${key} would be invalid AMP`).not.toContain(
187
+ "prefers-color-scheme",
188
+ );
189
+ }
190
+ });
191
+
192
+ describe("ampify", () => {
193
+ it("gives a sized image explicit layout", () => {
194
+ expect(
195
+ ampify(
196
+ '<img src="a.png" alt="A" width="600" height="200" class="csr-hero-normal">',
197
+ ),
198
+ ).toBe(
199
+ '<amp-img src="a.png" alt="A" width="600" height="200" class="csr-hero-normal" layout="intrinsic"></amp-img>',
200
+ );
201
+ });
202
+
203
+ it("degrades an unsized image to its alt text, keeping its spacing", () => {
204
+ // `../../content.ts` makes dimensions optional, so this surface must have an
205
+ // answer for their absence. `alt` is required exactly so that a channel with
206
+ // nothing else has something to say.
207
+ expect(
208
+ ampify('<img src="a.png" alt="A logo" class="csr-hero-tight">'),
209
+ ).toBe('<p class="csr-p-tight">A logo</p>');
210
+ });
211
+
212
+ it("leaves markup with no image alone", () => {
213
+ const plain = '<p class="csr-p-none">x</p>';
214
+ expect(ampify(plain)).toBe(plain);
215
+ });
216
+ });
217
+ });
@@ -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
+ });