@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
@@ -11,10 +11,10 @@
11
11
  * - Pure. `render` is a function of `(content, context)` — no clock, no
12
12
  * environment, no I/O. The copyright year comes from
13
13
  * `context.brand.copyrightYear`.
14
- * - Output is byte-identical to what the old `src/email/render` emitted. That is
15
- * the whole constraint of this migration, and `__tests__/render.test.ts`
16
- * proves it against the frozen output-parity golden which, now that the old
17
- * layer is deleted, is the only surviving record of those bytes.
14
+ * - Output is locked by `../../__tests__/render-snapshot.test.ts`. Byte-identity
15
+ * with the old `src/email/render` was the whole constraint of the migration
16
+ * and it held; ADR-CONTRACTS-087 discharged that proof once it shipped, so
17
+ * this channel's markup is now free to change reviewably, via the snapshot.
18
18
  * - `supports` answers true for every element type: email is the rich channel,
19
19
  * and there is nothing in the content model it cannot depict.
20
20
  */
@@ -25,16 +25,30 @@ 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
- /** One rendered email — the email channel's natural output type. */
30
+ /**
31
+ * One rendered email — the email channel's natural output type.
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.
39
+ */
31
40
  export interface RenderedEmail {
32
41
  /** Subject line — the notification's `metadata.title`. */
33
42
  subject: string;
34
- /** Plain-text body. */
43
+ /** Plain-text body (`text/plain`). */
35
44
  text: string;
36
- /** HTML body (every kind is dual-output). */
45
+ /** HTML body (`text/html`) — every kind is multi-output. */
37
46
  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;
38
52
  }
39
53
 
40
54
  /**
@@ -68,6 +82,7 @@ export const emailRenderer: Renderer<RenderedEmail> = {
68
82
  subject: content.metadata.title,
69
83
  text: textShell(lines),
70
84
  html: htmlShell(lines),
85
+ amp: ampShell(lines),
71
86
  };
72
87
  },
73
88
  };
@@ -5,7 +5,9 @@
5
5
  * module offered templates a component per intent (`greeting()`, `keyValue()`,
6
6
  * `footer()`) and each template chose which to call; here the CONTENT names the
7
7
  * intent and this module is the only thing that knows the markup. The strings
8
- * are the same strings the output-parity golden asserts them byte-for-byte.
8
+ * started as the same strings, byte-for-byte; they are free to diverge now
9
+ * (ADR-CONTRACTS-087) and `../../__tests__/render-snapshot.test.ts` is what
10
+ * makes each divergence a reviewed one.
9
11
  *
10
12
  * **Spacing is derived, because the content model does not carry it.** The old
11
13
  * layer passed a `Spacing` per block, and `../../kinds` deliberately dropped it:
@@ -43,22 +45,17 @@ import type {
43
45
  } from "../../content";
44
46
  import type { RenderContext } from "../../context";
45
47
 
46
- import {
47
- COMPANY_URL,
48
- FONT_SIZE,
49
- MONO,
50
- SPACING,
51
- type Spacing,
52
- } from "./constants";
48
+ import { COMPANY_URL, type Spacing } from "./constants";
53
49
  import { renderChatUnit } from "./chat";
54
50
  import { ctaButton } from "./cta";
55
51
  import { escapeHtml } from "./escape-html";
56
52
  import type { EmailLine } from "./shells";
53
+ import { styleClass } from "./styles";
57
54
 
58
55
  /** One `<p>` — the primitive every prose-ish element is built from. */
59
56
  function paragraph(html: string, text: string, spacing: Spacing): EmailLine {
60
57
  return {
61
- html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: ${SPACING[spacing]};">${html}</p>`,
58
+ html: `<p ${styleClass(`p-${spacing}`)}>${html}</p>`,
62
59
  text,
63
60
  spacing,
64
61
  };
@@ -89,7 +86,7 @@ function keyValueLine(row: KeyValueRow): { html: string; text: string } {
89
86
 
90
87
  /** The standalone `>> LABEL <<` box. */
91
88
  function ctaLine(cta: CallToAction, trailing: Spacing): EmailLine {
92
- const { html, text } = ctaButton(cta, SPACING[trailing]);
89
+ const { html, text } = ctaButton(cta, trailing);
93
90
  return { html, text, spacing: trailing };
94
91
  }
95
92
 
@@ -97,7 +94,7 @@ function ctaLine(cta: CallToAction, trailing: Spacing): EmailLine {
97
94
  function signatureLine(signer: string, year: number): EmailLine {
98
95
  const line = `ⓒ ${year} • ${signer}`;
99
96
  return {
100
- html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: ${SPACING.none};"><br><br><span style="color: #bbb;">/* EOM */</span><br>${escapeHtml(line)}<br><a href="${COMPANY_URL}" target="_blank" rel="noopener noreferrer" style="color: #0047FF; text-decoration: none;">${escapeHtml(COMPANY_URL)}</a></p>`,
97
+ html: `<p ${styleClass("p-none")}><br><br><span ${styleClass("eom", "faint")}>/* EOM */</span><br>${escapeHtml(line)}<br><a href="${escapeHtml(COMPANY_URL)}" target="_blank" rel="noopener noreferrer" ${styleClass("signature-link", "link")}>${escapeHtml(COMPANY_URL)}</a></p>`,
101
98
  text: `\n\n/* EOM */\n${line}\n${COMPANY_URL}`,
102
99
  spacing: "none",
103
100
  };
@@ -151,8 +148,18 @@ export function renderElement(
151
148
  case "warning": {
152
149
  // Fieldless by design — the banner IS the content, and its wording is the
153
150
  // renderer's (see `../../content.ts`).
151
+ //
152
+ // The colour is this channel's answer to "this is a security notice", and
153
+ // only the HTML surface can give it: plain text has no colour, so there
154
+ // the banner's own glyphs carry the whole signal.
154
155
  const text = "🆆🅰🆁🅽🅸🅽🅶";
155
- return [paragraph(escapeHtml(text), text, trailing)];
156
+ return [
157
+ paragraph(
158
+ `<span ${styleClass("warning", "destructive")}>${escapeHtml(text)}</span>`,
159
+ text,
160
+ trailing,
161
+ ),
162
+ ];
156
163
  }
157
164
 
158
165
  case "notice":
@@ -202,20 +209,29 @@ export function renderElement(
202
209
  case "divider":
203
210
  return [
204
211
  {
205
- html: `<hr style="border: none; border-top: 1px solid #bbb; margin: ${SPACING[trailing]};">`,
212
+ html: `<hr ${styleClass(`hr-${trailing}`, "faint")}>`,
206
213
  text: "---",
207
214
  spacing: trailing,
208
215
  },
209
216
  ];
210
217
 
211
- case "heroImage":
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.
223
+ const dimensions =
224
+ element.width !== undefined && element.height !== undefined
225
+ ? ` width="${element.width}" height="${element.height}"`
226
+ : "";
212
227
  return [
213
228
  {
214
- html: `<img src="${element.src}" alt="${escapeHtml(element.alt)}" style="max-width: 100%; margin: ${SPACING[trailing]};">`,
229
+ html: `<img src="${escapeHtml(element.src)}" alt="${escapeHtml(element.alt)}"${dimensions} ${styleClass(`hero-${trailing}`)}>`,
215
230
  text: element.alt,
216
231
  spacing: trailing,
217
232
  },
218
233
  ];
234
+ }
219
235
 
220
236
  default: {
221
237
  const _exhaustive: never = element;
@@ -8,11 +8,21 @@
8
8
  *
9
9
  * INVARIANTS:
10
10
  * - Pure functions of their input lines. No clock, no environment, no I/O.
11
- * - Byte-identical to what that module emitted. These strings are the email's
12
- * actual markup; the output-parity golden asserts them character-for-character.
11
+ * - These strings are the email's actual markup, and
12
+ * `../../__tests__/render-snapshot.test.ts` asserts them
13
+ * character-for-character.
13
14
  */
14
15
 
15
- import { MONO, type Spacing } from "./constants";
16
+ import { BASE_STYLE, DARK_STYLE, hoverStyle } from "./colors";
17
+ import type { Spacing } from "./constants";
18
+ import { MSO_CTA_STYLE } from "./cta";
19
+ import {
20
+ AMP_RECIPE_RULES,
21
+ ampify,
22
+ inlineOf,
23
+ inlineStyles,
24
+ styleClass,
25
+ } from "./styles";
16
26
 
17
27
  /**
18
28
  * One rendered line — both presentations of one thing the notification says,
@@ -29,17 +39,111 @@ export interface EmailLine {
29
39
  spacing: Spacing;
30
40
  }
31
41
 
32
- /** Wrap rendered lines in the shared `<!DOCTYPE>` monospace shell. */
42
+ /**
43
+ * Wrap rendered lines in the shared `<!DOCTYPE>` monospace shell.
44
+ *
45
+ * The two metas declare that this email renders in either scheme, and `<style>`
46
+ * is what makes the declaration true — see `./colors.ts`'s `DARK_STYLE`. The
47
+ * declaration is a promise: made without the stylesheet, Apple Mail would stop
48
+ * protecting our colours, apply its dark background, and leave the inline LIGHT
49
+ * text colour sitting on top of it. The two ship together or not at all.
50
+ *
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.
56
+ *
57
+ * The `<!--[if mso]>` block is the mirror image: it is read ONLY by the Outlook
58
+ * Word engine, and undoes the one layout that engine cannot render — a linked
59
+ * CTA's full-box click target. See `./cta.ts`'s `MSO_CTA_STYLE`.
60
+ */
33
61
  export function htmlShell(lines: EmailLine[]): string {
34
- const inner = lines
35
- .map((line) => line.html)
36
- .filter(Boolean)
37
- .join("\n");
62
+ // `inlineStyles` is spent on the ELEMENT markup only. The shell states its own
63
+ // two slots directly: they are not `EmailLine`s, and keeping the inliner away
64
+ // from the `<head>` means a stylesheet can never be rewritten by it — CSS has
65
+ // attribute selectors (`[class="x"]`), and today's blocks happen not to use one.
66
+ const inner = inlineStyles(
67
+ lines
68
+ .map((line) => line.html)
69
+ .filter(Boolean)
70
+ .join("\n"),
71
+ );
38
72
  return `<!DOCTYPE html>
39
73
  <html lang="en">
40
- <head><meta charset="UTF-8"></head>
41
- <body style="${MONO} color: #1a1a1a; margin: 0; padding: 0;">
42
- <div style="max-width: 520px; margin: 0 auto;">
74
+ <head><meta charset="UTF-8">
75
+ <meta name="color-scheme" content="light dark">
76
+ <meta name="supported-color-schemes" content="light dark">
77
+ <style>${BASE_STYLE}
78
+ ${DARK_STYLE}</style>
79
+ <!--[if mso]><style>${MSO_CTA_STYLE}</style><![endif]--></head>
80
+ <body style="${inlineOf("body")}">
81
+ <div style="${inlineOf("frame")}">
82
+ ${inner}
83
+ </div>
84
+ </body>
85
+ </html>`;
86
+ }
87
+
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")}>
43
147
  ${inner}
44
148
  </div>
45
149
  </body>
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Every declaration the email channel makes, and the two surfaces built from them
3
+ * (ADR-CONTRACTS-089).
4
+ *
5
+ * **Why this file exists.** AMP4EMAIL forbids inline `style` attributes; the html
6
+ * surface depends on them, because inline is the light baseline every client gets
7
+ * including the ones that strip `<style>` (see `./colors.ts`'s `roleDecl`). The two
8
+ * surfaces are opposed on exactly the styling axis, so the markup can belong to
9
+ * neither: `./render`, `./cta` and `./chat` emit CLASS-ONLY markup, and each shell
10
+ * spends this registry its own way — `htmlShell` inlines it, `ampShell` states it
11
+ * as `<style amp-custom>`. One markup, two transforms, no second copy to drift.
12
+ *
13
+ * This is deliberately NOT a third field on `EmailLine`. `../../renderer.ts`
14
+ * warns that the old `Block` "carried `html` and `text` together and would have
15
+ * carried a third surface bolted on"; a surface is a way of SPENDING the lines,
16
+ * not a thing a line carries.
17
+ *
18
+ * **Why this is not `./colors.ts`.** The split is not "colour vs layout" — it is
19
+ * *what a colour is* vs *which declarations an element makes, in what order*.
20
+ * Nothing here states a colour; it asks `./colors.ts` for one. That keeps colour
21
+ * changeable in exactly one place, and it means `__tests__/colors.test.ts`'s
22
+ * literal guard — which reads every `.ts` in this directory except `colors.ts` —
23
+ * covers this file for free. Keep it here, not in a subdirectory, and that stays
24
+ * true.
25
+ *
26
+ * **Why recipes and not utility classes.** A utility set (`cs-m-20`, `cs-fs-13`)
27
+ * cannot reproduce today's bytes, because declaration ORDER differs per slot:
28
+ * `dots-over-cta` is `font → dots → text-align → margin` and `dots-cell` is
29
+ * `width → text-align → font → dots`. Same declarations, different order, two
30
+ * recipes. A recipe carries the order; a utility set cannot. It also keeps the
31
+ * emitting modules readable — one class names one intent.
32
+ *
33
+ * INVARIANTS:
34
+ * - Pure data and pure functions of it. No clock, no environment, no I/O.
35
+ * - A recipe's declarations are UNTERMINATED. This module owns every separator,
36
+ * so a declaration cannot decide the joiner's output.
37
+ * - `cs-` classes ship; `csr-` classes do not — `__tests__/styles.test.ts`.
38
+ * - The html surface's bytes are locked by `../../__tests__/render-snapshot.test.ts`,
39
+ * which is what proves this registry reconstitutes them exactly.
40
+ */
41
+
42
+ import { classNameFor, palette, type Role, roleDecl } from "./colors";
43
+ import { CTA_PAD, FONT_SIZE, MONO_DECL, SPACING } from "./constants";
44
+
45
+ /** One slot's declarations, in the order they are written. Unterminated. */
46
+ type Recipe = readonly string[];
47
+
48
+ /**
49
+ * Every styled slot in the channel, named by what it is.
50
+ *
51
+ * Spacing-parameterized families are written out per `Spacing` rather than
52
+ * generated: `../../constants.ts` closes `Spacing` at three, the names are what
53
+ * `./render` reads, and three explicit lines are worth more than a clever fold.
54
+ */
55
+ const RECIPES = {
56
+ // --- ./shells.ts ---------------------------------------------------------
57
+ body: [
58
+ MONO_DECL,
59
+ roleDecl("text"),
60
+ roleDecl("background", "background-color"),
61
+ "margin: 0",
62
+ "padding: 0",
63
+ ],
64
+ /** The 520px column every email is poured into. */
65
+ frame: ["max-width: 520px", "margin: 0 auto"],
66
+
67
+ // --- ./render.ts ---------------------------------------------------------
68
+ "p-normal": [
69
+ MONO_DECL,
70
+ `font-size: ${FONT_SIZE}`,
71
+ `margin: ${SPACING.normal}`,
72
+ ],
73
+ "p-tight": [MONO_DECL, `font-size: ${FONT_SIZE}`, `margin: ${SPACING.tight}`],
74
+ "p-none": [MONO_DECL, `font-size: ${FONT_SIZE}`, `margin: ${SPACING.none}`],
75
+ /** The `/* EOM *\/` marker above the signer. */
76
+ eom: [roleDecl("faint")],
77
+ "signature-link": [roleDecl("link"), "text-decoration: none"],
78
+ warning: [roleDecl("destructive")],
79
+ "hr-normal": [
80
+ "border: none",
81
+ `border-top: 1px solid ${palette("light").faint}`,
82
+ `margin: ${SPACING.normal}`,
83
+ ],
84
+ "hr-tight": [
85
+ "border: none",
86
+ `border-top: 1px solid ${palette("light").faint}`,
87
+ `margin: ${SPACING.tight}`,
88
+ ],
89
+ "hr-none": [
90
+ "border: none",
91
+ `border-top: 1px solid ${palette("light").faint}`,
92
+ `margin: ${SPACING.none}`,
93
+ ],
94
+ "hero-normal": ["max-width: 100%", `margin: ${SPACING.normal}`],
95
+ "hero-tight": ["max-width: 100%", `margin: ${SPACING.tight}`],
96
+ "hero-none": ["max-width: 100%", `margin: ${SPACING.none}`],
97
+
98
+ // --- ./cta.ts ------------------------------------------------------------
99
+ "cta-box-normal": [
100
+ "display: inline-block",
101
+ `border: 1px solid ${palette("light").cta}`,
102
+ "border-radius: 2px",
103
+ `margin: ${SPACING.normal}`,
104
+ "max-width: 220px",
105
+ ],
106
+ "cta-box-tight": [
107
+ "display: inline-block",
108
+ `border: 1px solid ${palette("light").cta}`,
109
+ "border-radius: 2px",
110
+ `margin: ${SPACING.tight}`,
111
+ "max-width: 220px",
112
+ ],
113
+ "cta-box-none": [
114
+ "display: inline-block",
115
+ `border: 1px solid ${palette("light").cta}`,
116
+ "border-radius: 2px",
117
+ `margin: ${SPACING.none}`,
118
+ "max-width: 220px",
119
+ ],
120
+ /** A linked button's cell holds no padding — the anchor carries it. */
121
+ "cta-cell-linked": [
122
+ "text-align: center",
123
+ MONO_DECL,
124
+ `font-size: ${FONT_SIZE}`,
125
+ ],
126
+ /** An unlinked code is not clickable, so its padding stays on the cell. */
127
+ "cta-cell-code": [
128
+ `padding: ${CTA_PAD}`,
129
+ "text-align: center",
130
+ MONO_DECL,
131
+ `font-size: ${FONT_SIZE}`,
132
+ ],
133
+ "cta-label": ["text-decoration: underline"],
134
+ /** `display: block` + the padding is what makes the WHOLE box the click target. */
135
+ "cta-anchor": [
136
+ "display: block",
137
+ `padding: ${CTA_PAD}`,
138
+ roleDecl("link"),
139
+ "text-decoration: none",
140
+ ],
141
+ "cta-code": [roleDecl("link")],
142
+
143
+ // --- ./chat.ts -----------------------------------------------------------
144
+ "chat-rule-top": [
145
+ "border: none",
146
+ `border-top: 1px solid ${palette("light").faint}`,
147
+ "margin: 12px 0 24px 0",
148
+ ],
149
+ "chat-rule-bottom": [
150
+ "border: none",
151
+ `border-top: 1px solid ${palette("light").faint}`,
152
+ "margin: 24px 0 12px 0",
153
+ ],
154
+ /** A row that clears the bubbles: 24px. */
155
+ "chat-row-24": ["margin: 0 0 24px 0"],
156
+ /** A row hugging what follows it (a CTA, the dots): 16px. */
157
+ "chat-row-16": ["margin: 0 0 16px 0"],
158
+ "chat-attribution": [
159
+ MONO_DECL,
160
+ `font-size: ${FONT_SIZE}`,
161
+ roleDecl("meta"),
162
+ "text-align: right",
163
+ "padding-top: 6px",
164
+ "padding-right: 1ch",
165
+ ],
166
+ "chat-avatar-left": [
167
+ MONO_DECL,
168
+ `font-size: ${FONT_SIZE}`,
169
+ "padding-right: 8px",
170
+ "vertical-align: bottom",
171
+ "white-space: nowrap",
172
+ ],
173
+ /** The mirror that reserves the column's width without drawing the avatar. */
174
+ "chat-avatar-left-hidden": [
175
+ MONO_DECL,
176
+ `font-size: ${FONT_SIZE}`,
177
+ "padding-right: 8px",
178
+ "vertical-align: bottom",
179
+ "visibility: hidden",
180
+ "white-space: nowrap",
181
+ ],
182
+ "chat-avatar-right": [
183
+ MONO_DECL,
184
+ `font-size: ${FONT_SIZE}`,
185
+ "padding-left: 8px",
186
+ "vertical-align: bottom",
187
+ "white-space: nowrap",
188
+ ],
189
+ "chat-avatar-right-hidden": [
190
+ MONO_DECL,
191
+ `font-size: ${FONT_SIZE}`,
192
+ "padding-left: 8px",
193
+ "vertical-align: bottom",
194
+ "visibility: hidden",
195
+ "white-space: nowrap",
196
+ ],
197
+ "chat-channel-left": ["width: 100%", "text-align: left"],
198
+ "chat-channel-right": ["width: 100%", "text-align: right"],
199
+ "chat-bubble-wrap": [
200
+ "display: inline-block",
201
+ "max-width: 100%",
202
+ "vertical-align: bottom",
203
+ ],
204
+ "bubble-user": [
205
+ "border-radius: 8px 8px 0 8px",
206
+ "padding: 10px 14px",
207
+ "text-align: right",
208
+ MONO_DECL,
209
+ `font-size: ${FONT_SIZE}`,
210
+ roleDecl("onBubble"),
211
+ roleDecl("bubble", "background"),
212
+ ],
213
+ "bubble-assistant": [
214
+ "border-radius: 8px 8px 8px 0",
215
+ "padding: 10px 14px",
216
+ MONO_DECL,
217
+ `font-size: ${FONT_SIZE}`,
218
+ roleDecl("onBubble"),
219
+ roleDecl("bubble", "background"),
220
+ ],
221
+ /** Dots sharing the CTA's inline-block, so they centre over its exact width. */
222
+ "dots-over-cta": [
223
+ MONO_DECL,
224
+ "font-size: 20px",
225
+ "font-weight: bold",
226
+ roleDecl("dots"),
227
+ "text-align: center",
228
+ "margin: 0 0 16px 0",
229
+ ],
230
+ /** Standalone dots, centred in the message channel. */
231
+ "dots-cell": [
232
+ "width: 100%",
233
+ "text-align: center",
234
+ MONO_DECL,
235
+ "font-size: 20px",
236
+ "font-weight: bold",
237
+ roleDecl("dots"),
238
+ ],
239
+ "cta-stack": ["display: inline-block", "text-align: left"],
240
+ } as const satisfies Record<string, Recipe>;
241
+
242
+ /** Every styled slot, named. */
243
+ export type StyleName = keyof typeof RECIPES;
244
+
245
+ /** Every recipe name, for tests to iterate. */
246
+ export const STYLE_NAMES = Object.keys(RECIPES) as StyleName[];
247
+
248
+ /**
249
+ * The prefix that marks a class as the inliner's to consume.
250
+ *
251
+ * This is the whole mechanism. A `cs-` role class is a HOOK: `darkStyle` needs it
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.
257
+ */
258
+ const RECIPE_PREFIX = "csr-";
259
+
260
+ /** `bubble-user` → `csr-bubble-user`. Derived, never hand-typed. */
261
+ function recipeClass(name: StyleName): string {
262
+ return `${RECIPE_PREFIX}${name}`;
263
+ }
264
+
265
+ /**
266
+ * The class attribute for one slot: its roles, then its recipe.
267
+ *
268
+ * Roles come FIRST so that dropping the recipe leaves exactly the class attribute
269
+ * the html surface shipped before this registry existed — which is what makes
270
+ * `inlineStyles` a byte-for-byte identity rather than a re-render.
271
+ */
272
+ export function styleClass(name: StyleName, ...roles: Role[]): string {
273
+ const classes = [...roles.map(classNameFor), recipeClass(name)];
274
+ return `class="${classes.join(" ")}"`;
275
+ }
276
+
277
+ /** `border-radius: 8px; padding: 10px 14px;` — one recipe, inline. */
278
+ export function inlineOf(name: StyleName): string {
279
+ return `${RECIPES[name].join("; ")};`;
280
+ }
281
+
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
+ * Class-only markup → the html surface's markup.
289
+ *
290
+ * It never looks for elements; it rewrites CLASS ATTRIBUTES. Recipe classes become
291
+ * an inline `style`, role classes survive untouched, and a class attribute left
292
+ * with nothing in it disappears — which is what collapses `<p class="csr-p-normal">`
293
+ * back to `<p style="…">` rather than leaving a `class=""` behind.
294
+ *
295
+ * **Why a regex is safe here, and only here.** We control every class this can
296
+ * match, and `./escape-html.ts` escapes `"` — so no escaped content can close an
297
+ * attribute and forge one. The two values that do NOT pass through `escapeHtml`
298
+ * would be the hole, which is why `./cta.ts` and `./render.ts` escape `href` and
299
+ * `src` before interpolating: without that, a URL containing `" class="csr-body`
300
+ * is an HTML injection today and a style injection here.
301
+ */
302
+ export function inlineStyles(html: string): string {
303
+ return html.replace(/class="([^"]*)"/g, (_match, value: string) => {
304
+ const names = value.split(" ").filter(Boolean);
305
+ const hooks = names.filter((name) => !name.startsWith(RECIPE_PREFIX));
306
+ const decls = names
307
+ .filter((name) => name.startsWith(RECIPE_PREFIX))
308
+ .flatMap(
309
+ (name) => RECIPES[name.slice(RECIPE_PREFIX.length) as StyleName],
310
+ );
311
+ return [
312
+ hooks.length ? `class="${hooks.join(" ")}"` : "",
313
+ decls.length ? `style="${decls.join("; ")};"` : "",
314
+ ]
315
+ .filter(Boolean)
316
+ .join(" ");
317
+ });
318
+ }
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");