@company-semantics/contracts 36.0.0 → 37.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -144,10 +144,11 @@ 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: 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.
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.
151
152
  */
152
153
  export interface HeroImage {
153
154
  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` / `ampShell` / `textShell` — what makes one email three surfaces |
20
- | `styles.ts` | every declaration, as recipes — plus `inlineStyles` and `ampify` |
19
+ | `shells.ts` | `htmlShell` / `textShell` — what makes one email two surfaces |
20
+ | `styles.ts` | every declaration, as recipes — plus `inlineStyles` |
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,38 +27,43 @@ 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
- ## Three surfaces, one markup (ADR-CONTRACTS-089)
30
+ ## Two surfaces, one markup (ADR-CONTRACTS-089, -091)
31
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.
32
+ An email is a `multipart/alternative`: `text/plain` and `text/html` are two
33
+ presentations of ONE message, and `RenderedEmail` carries both.
35
34
 
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>`.
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.
46
38
 
47
39
  `EmailLine` gains no third field: `../../renderer.ts` warns against exactly that,
48
40
  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.
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
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.
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).
55
59
 
56
- **What AMP does not have:** dark. AMP4EMAIL disallows the `prefers-color-scheme`
57
- media feature outright. It costs nothing — see Colour.
60
+ Two things we learned the expensive way, so nobody re-derives them:
58
61
 
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
+ - `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.
62
67
 
63
68
  ## Colour — the thing this channel has and the others do not
64
69
 
@@ -148,29 +153,22 @@ expect to style it properly rather than trust the placeholder.
148
153
  `__tests__/colors.test.ts` fails on a raw hex anywhere else in the directory,
149
154
  including one in a comment, because a comment naming a literal goes stale the
150
155
  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.
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.
157
160
  - `:hover` is the one exception, because it has no inline form: BOTH schemes state
158
161
  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.
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`.
174
172
  - A hover hook goes on a CTA ONLY when it has an `href`. An unlinked label is a
175
173
  payload (an OTP code), and a hover state on it would promise a click that does
176
174
  not exist — the same invariant that keeps it a `<span>` rather than an `<a>`.
@@ -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, ruleOf, STYLE_NAMES } from "../styles";
22
+ import { inlineOf, inlineStyles, 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 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.
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.
106
107
  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,10 +57,11 @@ 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, 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.
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.
64
65
  */
65
66
  type ChatRow = "chat-row-16" | "chat-row-24";
66
67
 
@@ -270,24 +270,27 @@ const dark = PALETTES.dark;
270
270
  * the LIGHT hover; the dark block follows it in the shell and overrides it, so
271
271
  * source order is what resolves them (same specificity).
272
272
  *
273
- * Whether any of it happens is the client's call, and this is the ONE role whose
274
- * reach the AMP surface changes. Apple Mail honours `:hover`, new Outlook for Mac
275
- * and Yahoo partially, Gmail and classic Outlook not at all but Gmail is an AMP
276
- * client, and there this rule simply works. On the html surface there is no
277
- * fallback to write: without it the button rests, which is how it looks today.
278
- * Purely additive, like the dark path. See `./styles.ts` for the surface split.
273
+ * Whether any of it happens is the client's call. Apple Mail honours `:hover`, new
274
+ * Outlook for Mac and Yahoo partially, Gmail and classic Outlook not at all. There
275
+ * is no fallback to write: without it the button rests, which is how it looks
276
+ * today. Purely additive, like the dark path.
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.
279
288
  *
280
289
  * `.cs-cta-hover` is present ONLY on a CTA that actually has an `href` — see
281
290
  * `./cta.ts`. An OTP code wears `.cs-cta` for its edge and never this, because a
282
291
  * hover state on something unclickable promises a click that will not happen.
283
292
  */
284
- export function hoverStyle(important: boolean): string {
285
- const bang = important ? " !important" : "";
286
- return `.cs-cta-hover:hover { background-color: ${light.ctaHover}${bang}; }`;
287
- }
288
-
289
- /** The html surface's hover block. See `hoverStyle` for why it is `important`. */
290
- export const BASE_STYLE = hoverStyle(true);
293
+ export const BASE_STYLE = `.cs-cta-hover:hover { background-color: ${light.ctaHover} !important; }`;
291
294
 
292
295
  /**
293
296
  * The dark override.
@@ -301,15 +304,15 @@ export const BASE_STYLE = hoverStyle(true);
301
304
  * colour is inline, inline beats a stylesheet on specificity, and without
302
305
  * `!important` every rule here silently loses and dark quietly does nothing.
303
306
  *
304
- * **This block is the html surface's alone, and gives up nothing by being so.**
305
- * AMP4EMAIL disallows the `prefers-color-scheme` media feature outright verified
306
- * against the AMP validator, with and without `data-css-strict` — so the AMP
307
- * surface cannot carry these rules in any form, and does not try
308
- * (ADR-CONTRACTS-089). It costs nothing, because the two audiences are disjoint:
309
- * this block is read by Apple Mail, iOS Mail and Outlook for Mac (see
310
- * `./shells.ts`), and AMP is read by Gmail, Yahoo and Mail.ru — clients that
311
- * force-invert and never honoured it. Unlike `hoverStyle`, there was no version of
312
- * this worth parameterizing.
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.
313
316
  *
314
317
  * `.cs-faint` states both `color` and `border-top-color` because one role lands
315
318
  * on two properties — the `/* EOM *\/` span takes the first and ignores the
@@ -41,10 +41,8 @@ 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
- * 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`.
44
+ * It lives in the `<head>`, not in an `EmailLine`, which is why no surface ever had
45
+ * to strip it.
48
46
  */
49
47
  export const MSO_CTA_STYLE = `.cs-cta td { padding: ${CTA_PAD} !important; }
50
48
  .cs-cta a { padding: 0 !important; }`;
@@ -70,10 +68,10 @@ export function ctaButton(
70
68
 
71
69
  // The hover hook goes on ONLY when there is somewhere to go. An OTP code is a
72
70
  // box with no link in it, and lighting it up under the pointer would promise a
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).
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).
77
75
  const boxClass = href
78
76
  ? styleClass(`cta-box-${margin}`, "cta", "ctaHover")
79
77
  : styleClass(`cta-box-${margin}`, "cta");
@@ -25,17 +25,20 @@ import type { Renderer } from "../../renderer";
25
25
 
26
26
  import type { Spacing } from "./constants";
27
27
  import { renderElement } from "./render";
28
- import { ampShell, type EmailLine, htmlShell, textShell } from "./shells";
28
+ import { 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
- * 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). Assembling the parts is delivery's job, and delivery is not
37
- * contracts': today the backend sends `text` and `html` over SES's simple send and
38
- * `amp` goes nowhere.
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'.
36
+ *
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`.
39
42
  */
40
43
  export interface RenderedEmail {
41
44
  /** Subject line — the notification's `metadata.title`. */
@@ -44,11 +47,6 @@ export interface RenderedEmail {
44
47
  text: string;
45
48
  /** HTML body (`text/html`) — every kind is multi-output. */
46
49
  html: string;
47
- /**
48
- * AMP4EMAIL body (`text/x-amp-html`) — read by Gmail, Yahoo and Mail.ru, and
49
- * the only surface where the CTA's `:hover` reaches most recipients.
50
- */
51
- amp: string;
52
50
  }
53
51
 
54
52
  /**
@@ -82,7 +80,6 @@ export const emailRenderer: Renderer<RenderedEmail> = {
82
80
  subject: content.metadata.title,
83
81
  text: textShell(lines),
84
82
  html: htmlShell(lines),
85
- amp: ampShell(lines),
86
83
  };
87
84
  },
88
85
  };
@@ -216,10 +216,8 @@ export function renderElement(
216
216
  ];
217
217
 
218
218
  case "heroImage": {
219
- // Stated as attributes, not just style: they are the image's intrinsic
220
- // size, every client uses them to reserve space before it loads, and
221
- // `./styles.ts`'s `ampify` reads them to decide whether the AMP surface can
222
- // draw an `<amp-img>` at all.
219
+ // Stated as attributes, not just style: they are the image's intrinsic size
220
+ // and every client uses them to reserve space before it loads.
223
221
  const dimensions =
224
222
  element.width !== undefined && element.height !== undefined
225
223
  ? ` width="${element.width}" height="${element.height}"`
@@ -6,6 +6,9 @@
6
6
  * composes ONE list of `EmailLine`s and both surfaces derive from it, so the two
7
7
  * can never disagree about what the email says.
8
8
  *
9
+ * There was a third shell — `ampShell` — retired in ADR-CONTRACTS-091. See
10
+ * `./index.ts`: the AMP part cost Gmail readers their dark mode.
11
+ *
9
12
  * INVARIANTS:
10
13
  * - Pure functions of their input lines. No clock, no environment, no I/O.
11
14
  * - These strings are the email's actual markup, and
@@ -13,16 +16,10 @@
13
16
  * character-for-character.
14
17
  */
15
18
 
16
- import { BASE_STYLE, DARK_STYLE, hoverStyle } from "./colors";
19
+ import { BASE_STYLE, DARK_STYLE } from "./colors";
17
20
  import type { Spacing } from "./constants";
18
21
  import { MSO_CTA_STYLE } from "./cta";
19
- import {
20
- AMP_RECIPE_RULES,
21
- ampify,
22
- inlineOf,
23
- inlineStyles,
24
- styleClass,
25
- } from "./styles";
22
+ import { inlineOf, inlineStyles } from "./styles";
26
23
 
27
24
  /**
28
25
  * One rendered line — both presentations of one thing the notification says,
@@ -48,11 +45,16 @@ export interface EmailLine {
48
45
  * protecting our colours, apply its dark background, and leave the inline LIGHT
49
46
  * text colour sitting on top of it. The two ship together or not at all.
50
47
  *
51
- * Only Apple Mail, iOS Mail and Outlook for Mac read any of this. Clients that
52
- * force-invert (Outlook.com, Windows Outlook, Gmail Android) ignore both metas
53
- * and rewrite the inline styles post-delivery nothing here can stop that, which
54
- * is why the body still states an explicit background rather than trusting a
55
- * default.
48
+ * Only Apple Mail, iOS Mail and Outlook for Mac read any of this. No Gmail client
49
+ * honours `prefers-color-scheme` on any surface; what Gmail iOS does instead is
50
+ * invert this part post-delivery, which nothing here can stop and which is why
51
+ * the body still states an explicit background rather than trusting a default.
52
+ * That inversion is not a fallback we tolerate, it is the ONLY dark mode a Gmail
53
+ * iOS reader gets, and preserving it is why the AMP surface was retired
54
+ * (ADR-CONTRACTS-091): Gmail leaves an AMP part alone, so having one would have
55
+ * turned this inversion off. Measured in
56
+ * `company-semantics-backend/docs/dark-mode-probe.md` — Gmail web, by contrast,
57
+ * recolours nothing at all.
56
58
  *
57
59
  * The `<!--[if mso]>` block is the mirror image: it is read ONLY by the Outlook
58
60
  * Word engine, and undoes the one layout that engine cannot render — a linked
@@ -85,71 +87,6 @@ ${inner}
85
87
  </html>`;
86
88
  }
87
89
 
88
- /**
89
- * Wrap rendered lines in the AMP4EMAIL shell (ADR-CONTRACTS-089).
90
- *
91
- * The third MIME part of the same email — `text/x-amp-html`, beside `text/html`
92
- * and `text/plain`. Gmail, Yahoo and Mail.ru read it; everyone else reads the
93
- * html part and never knows this existed. That is the point: `./colors.ts`'s
94
- * `hoverStyle` is absent in Gmail on the html surface, and here it simply works.
95
- *
96
- * Everything AMP forbids is absent BY CONSTRUCTION rather than by stripping:
97
- *
98
- * - **No inline `style`.** The lines are class-only; only `htmlShell` inlines them.
99
- * - **No `!important`.** `hoverStyle(false)` — there is no inline style to beat
100
- * here, so nothing needs to win a specificity fight, and AMP forbids the keyword
101
- * anyway.
102
- * - **No `<!--[if mso]>`.** `MSO_CTA_STYLE` lives in `htmlShell`'s `<head>`, never
103
- * in a line, so nothing has to remove it. Outlook is not an AMP client, and
104
- * `display: block` needs no correction in the three that are.
105
- *
106
- * **No dark scheme, and that is not an omission.** AMP4EMAIL disallows the
107
- * `prefers-color-scheme` media feature — verified against the AMP validator, with
108
- * and without `data-css-strict` — so `DARK_STYLE` cannot appear here in any form.
109
- * It costs nothing: that block is read by Apple Mail, iOS Mail and Outlook for Mac,
110
- * and this surface is read by Gmail, Yahoo and Mail.ru. The two sets are disjoint,
111
- * and the AMP clients force-invert rather than honour a stylesheet — so they were
112
- * never receiving our dark colours on the html surface either. What this surface
113
- * adds is `:hover`, which those clients DO honour and which the html surface cannot
114
- * reach them with. It trades nothing away.
115
- *
116
- * The boilerplate hides the body until the runtime unhides it — which is why an
117
- * AMP document that cannot run scripts shows nothing at all, rather than showing
118
- * an unstyled version of itself.
119
- */
120
- export function ampShell(lines: EmailLine[]): string {
121
- const inner = ampify(
122
- lines
123
- .map((line) => line.html)
124
- .filter(Boolean)
125
- .join("\n"),
126
- );
127
- // `amp4email` rather than the `⚡4email` the spec also allows: both are valid,
128
- // and one of them survives every editor, terminal and diff it will ever cross.
129
- //
130
- // `data-css-strict` opts into AMP's strict CSS validation. The validator warns
131
- // when it is absent and says it "may become an error in the future"; our CSS
132
- // passes with and without it, so taking it now costs nothing and means that
133
- // future arrives already handled.
134
- //
135
- // The shell wears its own recipes as CLASSES, where `htmlShell` inlines them —
136
- // that asymmetry is the whole surface split, and it means `body` and `frame`
137
- // need no special case: `AMP_RECIPE_RULES` already states every recipe's rule.
138
- return `<!DOCTYPE html>
139
- <html amp4email data-css-strict lang="en">
140
- <head><meta charset="utf-8">
141
- <script async src="https://cdn.ampproject.org/v0.js"></script>
142
- <style amp4email-boilerplate>body{visibility:hidden}</style>
143
- <style amp-custom>${AMP_RECIPE_RULES}
144
- ${hoverStyle(false)}</style></head>
145
- <body ${styleClass("body")}>
146
- <div ${styleClass("frame")}>
147
- ${inner}
148
- </div>
149
- </body>
150
- </html>`;
151
- }
152
-
153
90
  /**
154
91
  * Join rendered lines into the plain-text email — separated per each line's
155
92
  * spacing, with a trailing newline.
@@ -250,10 +250,16 @@ export const STYLE_NAMES = Object.keys(RECIPES) as StyleName[];
250
250
  *
251
251
  * This is the whole mechanism. A `cs-` role class is a HOOK: `darkStyle` needs it
252
252
  * in the shipped markup, so it survives. A `csr-` recipe class is a BUNDLE OF
253
- * DECLARATIONS: the html surface spends it and drops it, the AMP surface keeps it
254
- * and states its rule. One prefix test separates them — for `inlineStyles` below,
255
- * and for `__tests__/colors.test.ts`, which asserts every SHIPPED class has a dark
256
- * rule and would otherwise trip over every recipe class in this file.
253
+ * DECLARATIONS: the html surface spends it and drops it. One prefix test separates
254
+ * them — for `inlineStyles` below, and for `__tests__/colors.test.ts`, which asserts
255
+ * every SHIPPED class has a dark rule and would otherwise trip over every recipe
256
+ * class in this file.
257
+ *
258
+ * The registry once had a second consumer — the AMP surface kept these classes and
259
+ * stated their rules, and that opposition (html inlines, AMP does not) is why it
260
+ * exists. AMP is gone (ADR-CONTRACTS-091) and the registry stays: it is now the
261
+ * single source for a recipe, spent in one place, and the markup it produces is
262
+ * byte-identical either way.
257
263
  */
258
264
  const RECIPE_PREFIX = "csr-";
259
265
 
@@ -279,11 +285,6 @@ export function inlineOf(name: StyleName): string {
279
285
  return `${RECIPES[name].join("; ")};`;
280
286
  }
281
287
 
282
- /** `.csr-bubble-user { border-radius: 8px; … }` — one recipe, as a rule. */
283
- export function ruleOf(name: StyleName): string {
284
- return `.${recipeClass(name)} { ${inlineOf(name)} }`;
285
- }
286
-
287
288
  /**
288
289
  * Class-only markup → the html surface's markup.
289
290
  *
@@ -316,46 +317,3 @@ export function inlineStyles(html: string): string {
316
317
  .join(" ");
317
318
  });
318
319
  }
319
-
320
- /**
321
- * Class-only markup → the AMP surface's markup.
322
- *
323
- * The mirror of `inlineStyles`, and the reason both exist: that one resolves the
324
- * surfaces' disagreement about STYLE, this one their disagreement about TAGS. AMP
325
- * keeps the classes (its stylesheet is what needs them), so all this has to do is
326
- * the one structural swap the surfaces cannot share.
327
- *
328
- * `<img>` → `<amp-img>`, which AMP requires and which needs explicit dimensions to
329
- * reserve layout before the image loads. `../../content.ts` makes them optional,
330
- * because a channel that can measure an image should not be told — so when they
331
- * are absent this surface CANNOT draw the image, and says the `alt` text instead.
332
- * That is a degradation, not a lie: `alt` is required exactly so a channel with
333
- * nothing else has something to say, and the html surface still draws the picture.
334
- *
335
- * This does not contradict `supports("heroImage") === true` — `../../renderer.ts`
336
- * puts `supports` on the RENDERER, and AMP is one of the email renderer's surfaces,
337
- * not a renderer. Email can depict a hero image. One of its three MIME parts is
338
- * plain text, which cannot, and that has never made the claim false.
339
- */
340
- export function ampify(html: string): string {
341
- return html.replace(/<img ([^>]*)>/g, (_match, attrs: string) => {
342
- const sized = /\bwidth="\d+"/.test(attrs) && /\bheight="\d+"/.test(attrs);
343
- if (sized) return `<amp-img ${attrs} layout="intrinsic"></amp-img>`;
344
- const alt = /\balt="([^"]*)"/.exec(attrs)?.[1] ?? "";
345
- // Carry the image's own spacing over to the paragraph replacing it, so the
346
- // gap below it does not change with the fallback.
347
- const spacing = /\bcsr-hero-(\w+)\b/.exec(attrs)?.[1] ?? "normal";
348
- return `<p class="${RECIPE_PREFIX}p-${spacing}">${alt}</p>`;
349
- });
350
- }
351
-
352
- /**
353
- * Every recipe as a rule, for `<style amp-custom>`.
354
- *
355
- * ALL of them, always — not just the ones this email happens to use. Emitting the
356
- * used subset would make the stylesheet a function of the content, so two emails
357
- * would carry two different stylesheets and the snapshot would stop being a
358
- * statement about the channel. ~40 rules is about 4KB against AMP's 75,000-byte
359
- * budget, which `__tests__/amp.test.ts` holds us to.
360
- */
361
- export const AMP_RECIPE_RULES = STYLE_NAMES.map(ruleOf).join("\n");