@company-semantics/contracts 37.0.0 → 38.1.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.
@@ -144,11 +144,10 @@ export interface Signature {
144
144
  * `width`/`height` are the image's intrinsic pixel dimensions. They are optional
145
145
  * because a channel that can measure an image itself does not need to be told, and
146
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.
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.
152
151
  */
153
152
  export interface HeroImage {
154
153
  type: "heroImage";
@@ -16,8 +16,8 @@ the markup. The strings are the same strings.
16
16
  | `render.ts` | each `NotificationElement` → its class-only `<p>`/table markup, and spacing |
17
17
  | `chat.ts` | `chatUnit` → HTML bubbles and plain-text box art |
18
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` |
19
+ | `shells.ts` | `htmlShell` / `ampShell` / `textShell` — what makes one email three surfaces |
20
+ | `styles.ts` | every declaration, as recipes — plus `inlineStyles` and `ampify` |
21
21
  | `colors.ts` | every colour, by role, in every scheme — and the dark stylesheet |
22
22
  | `constants.ts` | typography and layout (`MONO_DECL`, `FONT_SIZE`, `SPACING`, `CTA_PAD`) + `COMPANY_URL` |
23
23
  | `escape-html.ts` | `escapeHtml` |
@@ -27,43 +27,58 @@ before it was deleted. The brand NAME is deliberately not among them: it is
27
27
  `../../context`'s `COMPANY_NAME`, because it is the same answer on every channel
28
28
  and renderers read it from `context.brand`.
29
29
 
30
- ## Two surfaces, one markup (ADR-CONTRACTS-089, -091)
30
+ ## Three surfaces, one markup (ADR-CONTRACTS-089)
31
31
 
32
- An email is a `multipart/alternative`: `text/plain` and `text/html` are two
33
- presentations of ONE message, and `RenderedEmail` carries both.
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.
34
35
 
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.
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>`.
38
46
 
39
47
  `EmailLine` gains no third field: `../../renderer.ts` warns against exactly that,
40
48
  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.
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
+ ### ⚠️ The AMP body is NEVER SENT
54
+
55
+ Two of the three ship. `amp` is rendered, previewed in the Ladle gallery, and
56
+ dropped and **delivering it is a regression, not a feature**. The backend's
57
+ `EmailDelivery.test.ts` fails if it reaches the transport; that test is the
58
+ invariant, this section is its explanation.
59
+
60
+ **Why not sent.** Gmail iOS inverts the `text/html` part post-delivery and does NOT
61
+ touch a `text/x-amp-html` part. That inversion is the only dark mode a Gmail iOS
62
+ reader gets no Gmail client honours `prefers-color-scheme` on any surface so an
63
+ AMP part would switch it off and hand them a white email in a dark client. AMP buys
64
+ `:hover` in Gmail and would cost dark mode in Gmail. Measured, not argued:
65
+ `company-semantics-backend/docs/dark-mode-probe.md` (ADR-CONTRACTS-091, -092).
66
+
67
+ **Why it exists.** AMP is the only email format with a real animation model —
68
+ `amp-animation`, `amp-bind`, `amp-position-observer`, no arbitrary JS. Nothing else
69
+ can make the kaomoji companions blink once, three seconds after open. Keeping the
70
+ surface alive costs one inert field; rebuilding it later costs more. If Google ever
71
+ allows `prefers-color-scheme` here, or Gmail starts inverting AMP too, the trade
72
+ flips and this is ready.
73
+
74
+ **Light-only, permanently.** `prefers-color-scheme` in `<style amp-custom>` is a hard
75
+ validator error (explicit 14-feature allowlist, frozen since 2023, unchanged by
76
+ `data-css-strict`). The `media` ATTRIBUTE takes the same feature, PASSES the
77
+ validator — and Gmail's runtime ignores it. Both doors closed.
78
+
79
+ `pnpm validate:amp` runs the real AMP validator. `__tests__/amp.test.ts` asserts
80
+ the constraints we KNOW on every run, which is not the same claim: it passed once
81
+ while every fixture was invalid.
67
82
 
68
83
  ## Colour — the thing this channel has and the others do not
69
84
 
@@ -153,22 +168,32 @@ expect to style it properly rather than trust the placeholder.
153
168
  `__tests__/colors.test.ts` fails on a raw hex anywhere else in the directory,
154
169
  including one in a comment, because a comment naming a literal goes stale the
155
170
  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.
171
+ - On the HTML surface, light is inline and dark is the `<style>` block, and that is
172
+ the mechanism rather than duplication: one source each for two schemes, both from
173
+ `colors.ts`. Every dark declaration carries `!important` because the inline light
174
+ colour would otherwise win on specificity and dark would silently do nothing. On
175
+ the AMP surface there is no inline style, so the recipes carry light and source
176
+ order alone would resolve a cascade — which is moot, because AMP has no dark.
160
177
  - `:hover` is the one exception, because it has no inline form: BOTH schemes state
161
178
  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`.
179
+ order). On the HTML surface it is opportunistic — Apple Mail honours it, Gmail and
180
+ classic Outlook do not — and needs no fallback, because without it the button
181
+ simply rests. It is also the ONE rule the AMP surface shares, via
182
+ `hoverStyle(false)`; AMP forbids `!important`, which is why that template takes a
183
+ parameter and `DARK_STYLE` does not.
184
+ - The AMP surface has NO dark scheme, and that is why it is never SENT. AMP4EMAIL
185
+ disallows the `prefers-color-scheme` media feature (an explicit 14-feature
186
+ allowlist, unchanged by `data-css-strict`), so it cannot have one. ADR-CONTRACTS-089
187
+ called that free "AMP's readers force-invert and never got our dark colours
188
+ anyway". Half right: they never got them, because no Gmail client honours the media
189
+ feature on any surface. But Gmail web recolours nothing, Yahoo does not invert, and
190
+ Gmail iOS inverts the html part while EXEMPTING an AMP part — so an AMP part does
191
+ not lose a scheme its readers never had, it switches off the inversion that IS
192
+ their dark mode. Measured; see the AMP section above.
193
+ - Role hooks ride the AMP surface unstyled (`cs-faint`, `cs-bubble`, `cs-cta`,
194
+ `cs-link`), because their only rules live in `DARK_STYLE`. Their colours arrive
195
+ via each recipe's `roleDecl`, so this is dead bytes rather than a bug — the price
196
+ of one markup. `.cs-cta-hover` is the exception: it is the feature.
172
197
  - A hover hook goes on a CTA ONLY when it has an `href`. An unlinked label is a
173
198
  payload (an OTP code), and a hover state on it would promise a click that does
174
199
  not exist — the same invariant that keeps it a `<span>` rather than an `<a>`.
@@ -181,7 +206,11 @@ expect to style it properly rather than trust the placeholder.
181
206
  promise: made alone it stops Apple Mail protecting our colours while leaving
182
207
  light text on a dark background — worse than declaring light-only. This channel
183
208
  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.
209
+ - Dark reaches Apple Mail, iOS Mail and Outlook for Mac, and nobody else. Gmail
210
+ honours none of it on any surface, so a Gmail reader's dark mode is whatever their
211
+ client does to our light colours unaided: Gmail iOS inverts the html part, and
212
+ **Gmail web does nothing at all** — a light email in a dark client. That gap is
213
+ real and open, and the lever is inversion-indifferent authoring in `colors.ts`
214
+ (mid-tones, no pure `#ffffff`/`#000000` in large fields, solid-fill CTAs), not
215
+ another surface. The dark path is additive: a client that strips `<style>` keeps
216
+ the inline light colours, which is the status quo rather than a degradation.
@@ -0,0 +1,225 @@
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 (an explicit 14-feature allowlist in the validator's
178
+ // protoascii, `issues_as_error: true`, unchanged by `data-css-strict`, frozen
179
+ // since 2023), so this surface cannot have dark in any form.
180
+ //
181
+ // Nor can it via the back door: the `media` ATTRIBUTE on an AMP element takes
182
+ // the same feature and PASSES the validator — and Gmail's runtime ignores it,
183
+ // rendering a dark-gated and a light-gated block at once. Measured; see
184
+ // `company-semantics-backend/docs/dark-mode-probe.md`. Both doors are closed.
185
+ //
186
+ // The surface being light-only is exactly why it is never SENT: an AMP part
187
+ // switches off Gmail iOS's inversion, which is the only dark mode those readers
188
+ // get (ADR-CONTRACTS-091/-092). It is rendered for a future that needs AMP's
189
+ // animation model, not for delivery.
190
+ //
191
+ // This test exists so that "add dark mode to AMP" fails here with the reason,
192
+ // rather than in `pnpm validate:amp` with a CSS syntax error.
193
+ for (const { key, amp } of RENDERED) {
194
+ expect(amp, `${key} would be invalid AMP`).not.toContain(
195
+ "prefers-color-scheme",
196
+ );
197
+ }
198
+ });
199
+
200
+ describe("ampify", () => {
201
+ it("gives a sized image explicit layout", () => {
202
+ expect(
203
+ ampify(
204
+ '<img src="a.png" alt="A" width="600" height="200" class="csr-hero-normal">',
205
+ ),
206
+ ).toBe(
207
+ '<amp-img src="a.png" alt="A" width="600" height="200" class="csr-hero-normal" layout="intrinsic"></amp-img>',
208
+ );
209
+ });
210
+
211
+ it("degrades an unsized image to its alt text, keeping its spacing", () => {
212
+ // `../../content.ts` makes dimensions optional, so this surface must have an
213
+ // answer for their absence. `alt` is required exactly so that a channel with
214
+ // nothing else has something to say.
215
+ expect(
216
+ ampify('<img src="a.png" alt="A logo" class="csr-hero-tight">'),
217
+ ).toBe('<p class="csr-p-tight">A logo</p>');
218
+ });
219
+
220
+ it("leaves markup with no image alone", () => {
221
+ const plain = '<p class="csr-p-none">x</p>';
222
+ expect(ampify(plain)).toBe(plain);
223
+ });
224
+ });
225
+ });
@@ -19,7 +19,7 @@ import { describe, expect, it } from "vitest";
19
19
  import type { NotificationContent } from "../../../content";
20
20
  import type { RenderContext } from "../../../context";
21
21
  import { emailRenderer } from "../index";
22
- import { inlineOf, inlineStyles, STYLE_NAMES } from "../styles";
22
+ import { inlineOf, inlineStyles, ruleOf, STYLE_NAMES } from "../styles";
23
23
 
24
24
  const CONTEXT: RenderContext = {
25
25
  brand: { name: "Company Semantics", copyrightYear: 2026 },
@@ -100,11 +100,11 @@ describe("the style registry", () => {
100
100
  expect(inlineStyles(plain)).toBe(plain);
101
101
  });
102
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.
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.
107
106
  for (const name of STYLE_NAMES) {
107
+ expect(ruleOf(name)).toBe(`.csr-${name} { ${inlineOf(name)} }`);
108
108
  expect(inlineOf(name).endsWith(";")).toBe(true);
109
109
  }
110
110
  });
@@ -57,11 +57,10 @@ const CHAT_INDENT = " ";
57
57
  * The gap under a chat row: 24px clears the bubbles, 16px hugs whatever the row
58
58
  * is introducing (a CTA, the dots).
59
59
  *
60
- * A recipe name rather than a margin string. The AMP surface first forced this
61
- * a margin could not be inline there — and it earns its keep without that surface
62
- * (ADR-CONTRACTS-091): naming the two the layout actually has is what makes a third
63
- * one a deliberate addition to `./styles.ts` rather than a new string appearing at
64
- * a call site.
60
+ * A recipe name rather than a margin string, because a margin cannot be inline on
61
+ * the AMP surface (ADR-CONTRACTS-089) — and naming the two the layout actually has
62
+ * is what makes a third one a deliberate addition to `./styles.ts` rather than a
63
+ * new string appearing at a call site.
65
64
  */
66
65
  type ChatRow = "chat-row-16" | "chat-row-24";
67
66
 
@@ -275,22 +275,23 @@ const dark = PALETTES.dark;
275
275
  * is no fallback to write: without it the button rests, which is how it looks
276
276
  * today. Purely additive, like the dark path.
277
277
  *
278
- * Gmail never gets it, and that is now a settled trade rather than a gap. The AMP
279
- * surface existed to reach exactly this one role in Gmail and cost Gmail iOS
280
- * readers their dark mode to do it, since Gmail leaves an AMP part un-inverted.
281
- * A hover state is worth less than a legible email (ADR-CONTRACTS-091).
282
- *
283
- * `!important` is load-bearing: the light colour is inline, inline beats a
284
- * stylesheet, and without it this rule silently loses. It used to be a parameter,
285
- * because the AMP surface had no inline style to beat and AMP forbids the keyword.
286
- * With that surface gone there is one caller, and a parameter with one caller is
287
- * not a seam.
278
+ * This is the ONE role whose reach the AMP surface would change Gmail honours
279
+ * `:hover` there and it is why that surface exists. It is NOT why the surface is
280
+ * unsent: shipping it would cost Gmail iOS its dark mode, which is worth more than
281
+ * a hover state (ADR-CONTRACTS-091/-092). The `important` parameter is what lets
282
+ * one rule serve both shells; see `./shells.ts`.
288
283
  *
289
284
  * `.cs-cta-hover` is present ONLY on a CTA that actually has an `href` — see
290
285
  * `./cta.ts`. An OTP code wears `.cs-cta` for its edge and never this, because a
291
286
  * hover state on something unclickable promises a click that will not happen.
292
287
  */
293
- export const BASE_STYLE = `.cs-cta-hover:hover { background-color: ${light.ctaHover} !important; }`;
288
+ export function hoverStyle(important: boolean): string {
289
+ const bang = important ? " !important" : "";
290
+ return `.cs-cta-hover:hover { background-color: ${light.ctaHover}${bang}; }`;
291
+ }
292
+
293
+ /** The html surface's hover block. See `hoverStyle` for why it is `important`. */
294
+ export const BASE_STYLE = hoverStyle(true);
294
295
 
295
296
  /**
296
297
  * The dark override.
@@ -304,15 +305,26 @@ export const BASE_STYLE = `.cs-cta-hover:hover { background-color: ${light.ctaHo
304
305
  * colour is inline, inline beats a stylesheet on specificity, and without
305
306
  * `!important` every rule here silently loses and dark quietly does nothing.
306
307
  *
307
- * **Read by Apple Mail, iOS Mail and Outlook for Mac, and by nobody else.** No
308
- * Gmail client honours `prefers-color-scheme` on any surface, and Yahoo rewrites
309
- * the query rather than honouring it so this block has never reached them and a
310
- * Gmail reader's dark mode is whatever their client does to our light colours on
311
- * its own. On Gmail iOS that is a post-delivery inversion of the html part; on
312
- * Gmail web it is nothing at all, and a light email stays light in a dark client.
313
- * Closing THAT gap is inversion-indifferent authoring here mid-tones, no pure
314
- * #ffffff/#000000 in large fields, solid-fill CTAs not another surface. See
315
- * `company-semantics-backend/docs/dark-mode-probe.md` and ADR-CONTRACTS-091.
308
+ * **This block is the html surface's alone, and that is why the AMP surface is
309
+ * never sent.** AMP4EMAIL disallows the `prefers-color-scheme` media feature
310
+ * outright a hard validator error, checked with and without `data-css-strict`
311
+ * so the AMP surface cannot carry these rules in any form (ADR-CONTRACTS-089).
312
+ * Unlike `hoverStyle`, there was no version of this worth parameterizing.
313
+ *
314
+ * ADR-CONTRACTS-089 called that free, on the grounds that AMP's readers force-invert
315
+ * and never received these colours anyway. **The first half is true and the second is
316
+ * false.** Nobody in Gmail or Yahoo honours `prefers-color-scheme`, so this block
317
+ * genuinely never reached them — but Gmail web recolours nothing, Yahoo does not
318
+ * invert, and Gmail iOS inverts the html part while EXEMPTING an AMP part. So an AMP
319
+ * part does not lose a scheme its readers never got; it switches off the client-side
320
+ * inversion that IS their dark mode. Measured in
321
+ * `company-semantics-backend/docs/dark-mode-probe.md`; the surface stays unsent
322
+ * (ADR-CONTRACTS-091/-092).
323
+ *
324
+ * **Which leaves a real gap this block cannot close.** Gmail web recolours nothing
325
+ * and honours nothing, so a light email stays light in a dark client. The lever there
326
+ * is inversion-indifferent authoring in this file — mid-tones, no pure `#ffffff` /
327
+ * `#000000` in large fields, solid-fill CTAs — not another surface.
316
328
  *
317
329
  * `.cs-faint` states both `color` and `border-top-color` because one role lands
318
330
  * on two properties — the `/* EOM *\/` span takes the first and ignores the
@@ -41,8 +41,10 @@ const CTA_BOX_PAD = 3;
41
41
  * it survives `inlineStyles` untouched — and its `!important` still beats the
42
42
  * inline padding, exactly as before.
43
43
  *
44
- * It lives in the `<head>`, not in an `EmailLine`, which is why no surface ever had
45
- * to strip it.
44
+ * The AMP surface never composes it: Outlook's Word engine is not an AMP client,
45
+ * `display: block` works natively in Gmail/Yahoo/Mail.ru, and AMP forbids both
46
+ * conditional comments and `!important`. Nothing has to strip it — it lives in the
47
+ * `<head>`, not in an `EmailLine`.
46
48
  */
47
49
  export const MSO_CTA_STYLE = `.cs-cta td { padding: ${CTA_PAD} !important; }
48
50
  .cs-cta a { padding: 0 !important; }`;
@@ -68,10 +70,10 @@ export function ctaButton(
68
70
 
69
71
  // The hover hook goes on ONLY when there is somewhere to go. An OTP code is a
70
72
  // box with no link in it, and lighting it up under the pointer would promise a
71
- // click that does not exist. `:hover` is opportunistic — see `./colors.ts`'s
72
- // `BASE_STYLE` — so it adds a state in Apple Mail and changes nothing in Gmail.
73
- // Reaching Gmail with it was the AMP surface's whole purpose, and cost Gmail iOS
74
- // its dark mode to do it (ADR-CONTRACTS-091).
73
+ // click that does not exist. On the html surface `:hover` is opportunistic — see
74
+ // `./colors.ts`'s `hoverStyle` — so it adds a state in Apple Mail and changes
75
+ // nothing in Gmail; on the AMP surface it simply works, which is what that
76
+ // surface is FOR (ADR-CONTRACTS-089).
75
77
  const boxClass = href
76
78
  ? styleClass(`cta-box-${margin}`, "cta", "ctaHover")
77
79
  : styleClass(`cta-box-${margin}`, "cta");
@@ -25,20 +25,19 @@ import type { Renderer } from "../../renderer";
25
25
 
26
26
  import type { Spacing } from "./constants";
27
27
  import { renderElement } from "./render";
28
- import { type EmailLine, htmlShell, textShell } from "./shells";
28
+ import { ampShell, type EmailLine, htmlShell, textShell } from "./shells";
29
29
 
30
30
  /**
31
31
  * One rendered email — the email channel's natural output type.
32
32
  *
33
- * Two bodies, because an email IS a `multipart/alternative`: one message, whose
34
- * parts a client chooses between. Assembling the parts is delivery's job, and
35
- * delivery is not contracts'.
33
+ * Three bodies, because an email IS a `multipart/alternative`: one message, whose
34
+ * parts a client chooses between. This is NOT three channels a recipient gets one
35
+ * email — which is why AMP lives here rather than behind a second `Renderer`
36
+ * (ADR-CONTRACTS-089).
36
37
  *
37
- * There was a third `amp`, an AMP4EMAIL body and it is gone
38
- * (ADR-CONTRACTS-091). It bought `:hover` on the CTA for Gmail readers and cost
39
- * them their dark mode: Gmail iOS inverts the html part and does NOT touch the AMP
40
- * part, so an AMP part opts those readers OUT of the only dark scheme they receive.
41
- * That is measured, not argued — `company-semantics-backend/docs/dark-mode-probe.md`.
38
+ * **Two of them ship.** Assembling the parts is delivery's job, and delivery is not
39
+ * contracts' but the `amp` body is not merely un-plumbed, it is deliberately unsent
40
+ * and must stay that way (ADR-CONTRACTS-092). See the field.
42
41
  */
43
42
  export interface RenderedEmail {
44
43
  /** Subject line — the notification's `metadata.title`. */
@@ -47,6 +46,19 @@ export interface RenderedEmail {
47
46
  text: string;
48
47
  /** HTML body (`text/html`) — every kind is multi-output. */
49
48
  html: string;
49
+ /**
50
+ * AMP4EMAIL body (`text/x-amp-html`).
51
+ *
52
+ * ⚠️ **Rendered, and deliberately NEVER SENT** (ADR-CONTRACTS-092). Delivering it
53
+ * is a regression: Gmail iOS inverts the html part and does NOT touch an AMP
54
+ * part, so an AMP part switches off the only dark mode a Gmail iOS reader gets.
55
+ * `EmailDelivery.test.ts` in the backend fails if this reaches the transport.
56
+ *
57
+ * It is kept alive for AMP's animation model (`amp-animation`, `amp-bind`),
58
+ * which no other email format has, and which is the one thing that could make it
59
+ * worth sending. See `./shells.ts`'s `ampShell`.
60
+ */
61
+ amp: string;
50
62
  }
51
63
 
52
64
  /**
@@ -80,6 +92,7 @@ export const emailRenderer: Renderer<RenderedEmail> = {
80
92
  subject: content.metadata.title,
81
93
  text: textShell(lines),
82
94
  html: htmlShell(lines),
95
+ amp: ampShell(lines),
83
96
  };
84
97
  },
85
98
  };