@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
@@ -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
@@ -48,12 +103,16 @@ expect to style it properly rather than trust the placeholder.
48
103
 
49
104
  ## Invariants
50
105
 
51
- - Output is byte-identical to what the old `src/email/render` emitted. This is
52
- the whole constraint of the migration. `__tests__/render.test.ts` proves it by
53
- composing every fixture in the frozen output-parity golden and rendering it
54
- through this channel a diff there means a real sent email changed. The golden
55
- is the only record of those bytes now, so it is the authority: fix the
56
- renderer, never the golden.
106
+ - Output is locked by `../../__tests__/render-snapshot.test.ts`, across every
107
+ fixture in `../../__tests__/fixtures.ts` a diff there means a real sent email
108
+ changed, so review it before regenerating. Byte-identity with the old
109
+ `src/email/render` was the whole constraint of the migration; it held, shipped
110
+ in 35.0.0, and ADR-CONTRACTS-087 retired the golden that proved it, because
111
+ that proof could not double as permission to change the design.
112
+ - `__tests__/render.test.ts` asserts the rules that outlive any redesign — every
113
+ element type supported, subject from the content's title, year and signer from
114
+ context, the `:` restored, user content escaped. Markup belongs in the
115
+ snapshot, not there.
57
116
  - PURE. `render` is a function of `(content, context)`: no clock, no environment,
58
117
  no I/O. The copyright year comes from `context.brand.copyrightYear`, which is
59
118
  what retires the `new Date().getFullYear()` hidden inside the old
@@ -67,8 +126,64 @@ expect to style it properly rather than trust the placeholder.
67
126
  - Values arrive pre-formatted: dates, title-casing and truncation are `compose`'s
68
127
  work. Nothing here re-formats a value.
69
128
  - Every user-controlled field in the HTML surface passes through `escapeHtml`
70
- before interpolation.
71
- - `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
72
142
  from the domain barrel: a margin is not vocabulary. They were duplicated from
73
143
  the old `email/render/blocks`'s private copies while both layers existed; that
74
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.
@@ -4,26 +4,29 @@
4
4
 
5
5
  Tests for the email channel (ADR-CONTRACTS-086).
6
6
 
7
- `render.test.ts` holds the renderer's one load-bearing proof: every fixture in
8
- `../../../__tests__/output-parity.golden.ts`, composed through
9
- `NOTIFICATION_DEFINITIONS` and rendered through `emailRenderer`, byte-for-byte.
10
- The rest of the file pins the properties the golden cannot see that `supports`
11
- is total, that the year comes from `RenderContext`, that the `:` separator the
12
- content model drops is put back, and that user-controlled fields are escaped.
7
+ `render.test.ts` pins the renderer's behavioural rules the claims that hold no
8
+ matter how the email is styled: that `supports` is total, that the subject comes
9
+ from the content's title, that the year and signer come from `RenderContext`
10
+ rather than a clock, that the `:` separator the content model drops is put back,
11
+ and that user-controlled fields are escaped. It also renders every fixture in
12
+ `../../../__tests__/fixtures.ts` through `NOTIFICATION_DEFINITIONS`, which proves
13
+ each kind composes and renders on both surfaces at all.
14
+
15
+ The MARKUP is not here. `../../../__tests__/render-snapshot.test.ts` locks that.
13
16
 
14
17
  ## Invariants
15
18
 
16
- - The golden is the ORACLE, not this renderer's output. When the two disagree,
17
- the renderer is wrong: the golden records what the old `src/email/render`
18
- emitted at 32.0.0, and "no rendered email changed" is the migration's whole
19
- promise. It is never regenerated to make this file pass — and since that layer
20
- is now deleted, the golden is the only record of those bytes, so regenerating
21
- it destroys the evidence rather than updating it.
19
+ - Rules here, markup there. A test in this file that asserts what the HTML looks
20
+ like is in the wrong file: it belongs in the snapshot, where a redesign shows
21
+ up as a reviewable diff instead of a wall of broken assertions.
22
22
  - NO fake timers. The old layer's `signature()` read the wall clock, which is why
23
23
  every render test had to freeze it; here the year is a field on
24
24
  `RenderContext`, and keeping this file clock-free is the improvement being
25
25
  locked in.
26
- - Parity is asserted at two levels against one golden: here through the RENDERER
27
- (composed content bytes), and in `../../../__tests__/output-parity.test.ts`
28
- through the ENTRY POINT (`renderToChannel`, the whole pipeline). Either one
29
- drifting from the golden is a failure.
26
+ - This file used to assert every fixture byte-for-byte against a frozen golden of
27
+ what `src/email/render` emitted at 32.0.0 the migration's "no rendered email
28
+ changed" promise, checked here at the RENDERER and in a companion suite at the
29
+ ENTRY POINT (`renderToChannel`). The promise held and shipped in 35.0.0;
30
+ ADR-CONTRACTS-087 retired both, because byte-equality with a deleted layer
31
+ cannot survive email design being deliberately changed. The bytes are in git at
32
+ `v35.0.0`.
@@ -0,0 +1,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
+ });