@company-semantics/contracts 35.1.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.
@@ -7,19 +7,46 @@
7
7
  * (`./chat`), which place it at different margins.
8
8
  *
9
9
  * INVARIANTS:
10
- * - Pure, and byte-identical to what the deleted `src/email/render/blocks` emits.
10
+ * - Pure. The markup is CLASS-ONLY (ADR-CONTRACTS-089) what ships is whatever
11
+ * the surface's shell makes of it. `../../__tests__/render-snapshot.test.ts`
12
+ * locks the html surface's bytes.
11
13
  * - `href` absent means the label IS the payload (an OTP code): the button must
12
14
  * not become a link, and plain text must not print a URL under it.
13
15
  */
14
16
 
15
17
  import type { CallToAction } from "../../content";
16
18
 
17
- import { FONT_SIZE, MONO } from "./constants";
19
+ import { CTA_PAD, type Spacing } from "./constants";
18
20
  import { escapeHtml } from "./escape-html";
21
+ import { styleClass } from "./styles";
19
22
 
20
23
  /** Padding columns on each side of the `>> LABEL <<` line in the ASCII box. */
21
24
  const CTA_BOX_PAD = 3;
22
25
 
26
+ /**
27
+ * Outlook's answer to a full-box click target.
28
+ *
29
+ * A linked button makes its whole box clickable by moving the padding onto the
30
+ * `<a>` and setting `display: block`. Outlook's Word engine supports `display:
31
+ * none` and no other value, so the anchor stays inline there, and Word drops
32
+ * vertical padding on an inline element — the button would render cramped against
33
+ * its border. This MSO-only block puts the padding back on the cell and zeroes it
34
+ * on the anchor, so Outlook renders exactly today's padded, text-clickable box
35
+ * while every honouring client gets the full-box target.
36
+ *
37
+ * Lives here rather than in `./styles.ts`: that registry holds the declarations an
38
+ * ELEMENT makes, and `htmlShell` spends them inline. This is a `<head>` rule aimed
39
+ * at one engine, and `./shells.ts` composes it behind an `<!--[if mso]>` gate the
40
+ * same way it composes the colour blocks. It rides on `.cs-cta`, a ROLE class, so
41
+ * it survives `inlineStyles` untouched — and its `!important` still beats the
42
+ * inline padding, exactly as before.
43
+ *
44
+ * It lives in the `<head>`, not in an `EmailLine`, which is why no surface ever had
45
+ * to strip it.
46
+ */
47
+ export const MSO_CTA_STYLE = `.cs-cta td { padding: ${CTA_PAD} !important; }
48
+ .cs-cta a { padding: 0 !important; }`;
49
+
23
50
  /** `>> LABEL <<` ASCII box (`*` corners): 3 lines, sized to the label. */
24
51
  function asciiCtaBox(label: string): string {
25
52
  const pad = " ".repeat(CTA_BOX_PAD);
@@ -35,24 +62,45 @@ function asciiCtaBox(label: string): string {
35
62
  */
36
63
  export function ctaButton(
37
64
  cta: CallToAction,
38
- margin: string,
65
+ margin: Spacing,
39
66
  ): { html: string; text: string } {
40
67
  const { label, href } = cta;
41
68
 
42
- const tableStyle = `display: inline-block; border: 1px solid #666; border-radius: 2px; margin: ${margin}; max-width: 220px;`;
43
- const tdStyle = `padding: 16px 24px; text-align: center; ${MONO} font-size: ${FONT_SIZE};`;
69
+ // The hover hook goes on ONLY when there is somewhere to go. An OTP code is a
70
+ // 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).
75
+ const boxClass = href
76
+ ? styleClass(`cta-box-${margin}`, "cta", "ctaHover")
77
+ : styleClass(`cta-box-${margin}`, "cta");
78
+ // A linked button carries its padding on the anchor (below), so the WHOLE box is
79
+ // the click target; the cell then holds none. An unlinked code is not clickable,
80
+ // so its padding stays on the cell and the label is a bare `<span>` — today's
81
+ // box exactly. Outlook ignores the anchor's `display: block` and is corrected by
82
+ // `MSO_CTA_STYLE`, which puts this same padding back on the cell there.
83
+ const cellClass = href
84
+ ? styleClass("cta-cell-linked")
85
+ : styleClass("cta-cell-code");
44
86
 
45
87
  // Underline only the label text (not the chevrons/spaces), and only when linked.
46
88
  const labelHtml = href
47
- ? `<span style="text-decoration: underline;">${escapeHtml(label)}</span>`
89
+ ? `<span ${styleClass("cta-label")}>${escapeHtml(label)}</span>`
48
90
  : escapeHtml(label);
49
91
  const chevrons = `&gt;&gt; ${labelHtml} &lt;&lt;`;
92
+ // Both wear the `link` colour — a call to action looks like one whether or not
93
+ // it goes anywhere, and the box's edge is already that colour. What separates
94
+ // them is the UNDERLINE above and the `<a>` itself: an unlinked label is a
95
+ // `<span>`, so an OTP code is still not clickable and still prints no URL. The
96
+ // linked anchor fills the box (`display: block` + the padding) so a click
97
+ // anywhere in it counts.
50
98
  const inner = href
51
- ? `<a href="${href}" style="color: #0047FF; text-decoration: none;">${chevrons}</a>`
52
- : chevrons;
99
+ ? `<a href="${escapeHtml(href)}" ${styleClass("cta-anchor", "link")}>${chevrons}</a>`
100
+ : `<span ${styleClass("cta-code", "link")}>${chevrons}</span>`;
53
101
 
54
- const html = `<table cellpadding="0" cellspacing="0" border="0" style="${tableStyle}">
55
- <tr><td style="${tdStyle}">
102
+ const html = `<table cellpadding="0" cellspacing="0" border="0" ${boxClass}>
103
+ <tr><td ${cellClass}>
56
104
  ${inner}
57
105
  </td></tr>
58
106
  </table>`;
@@ -27,13 +27,25 @@ import type { Spacing } from "./constants";
27
27
  import { renderElement } from "./render";
28
28
  import { 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
+ * 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`.
42
+ */
31
43
  export interface RenderedEmail {
32
44
  /** Subject line — the notification's `metadata.title`. */
33
45
  subject: string;
34
- /** Plain-text body. */
46
+ /** Plain-text body (`text/plain`). */
35
47
  text: string;
36
- /** HTML body (every kind is dual-output). */
48
+ /** HTML body (`text/html`) — every kind is multi-output. */
37
49
  html: string;
38
50
  }
39
51
 
@@ -45,23 +45,17 @@ import type {
45
45
  } from "../../content";
46
46
  import type { RenderContext } from "../../context";
47
47
 
48
- import {
49
- COMPANY_URL,
50
- DESTRUCTIVE,
51
- FONT_SIZE,
52
- MONO,
53
- SPACING,
54
- type Spacing,
55
- } from "./constants";
48
+ import { COMPANY_URL, type Spacing } from "./constants";
56
49
  import { renderChatUnit } from "./chat";
57
50
  import { ctaButton } from "./cta";
58
51
  import { escapeHtml } from "./escape-html";
59
52
  import type { EmailLine } from "./shells";
53
+ import { styleClass } from "./styles";
60
54
 
61
55
  /** One `<p>` — the primitive every prose-ish element is built from. */
62
56
  function paragraph(html: string, text: string, spacing: Spacing): EmailLine {
63
57
  return {
64
- html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: ${SPACING[spacing]};">${html}</p>`,
58
+ html: `<p ${styleClass(`p-${spacing}`)}>${html}</p>`,
65
59
  text,
66
60
  spacing,
67
61
  };
@@ -92,7 +86,7 @@ function keyValueLine(row: KeyValueRow): { html: string; text: string } {
92
86
 
93
87
  /** The standalone `>> LABEL <<` box. */
94
88
  function ctaLine(cta: CallToAction, trailing: Spacing): EmailLine {
95
- const { html, text } = ctaButton(cta, SPACING[trailing]);
89
+ const { html, text } = ctaButton(cta, trailing);
96
90
  return { html, text, spacing: trailing };
97
91
  }
98
92
 
@@ -100,7 +94,7 @@ function ctaLine(cta: CallToAction, trailing: Spacing): EmailLine {
100
94
  function signatureLine(signer: string, year: number): EmailLine {
101
95
  const line = `ⓒ ${year} • ${signer}`;
102
96
  return {
103
- 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>`,
104
98
  text: `\n\n/* EOM */\n${line}\n${COMPANY_URL}`,
105
99
  spacing: "none",
106
100
  };
@@ -161,7 +155,7 @@ export function renderElement(
161
155
  const text = "🆆🅰🆁🅽🅸🅽🅶";
162
156
  return [
163
157
  paragraph(
164
- `<span style="color: ${DESTRUCTIVE};">${escapeHtml(text)}</span>`,
158
+ `<span ${styleClass("warning", "destructive")}>${escapeHtml(text)}</span>`,
165
159
  text,
166
160
  trailing,
167
161
  ),
@@ -215,20 +209,27 @@ export function renderElement(
215
209
  case "divider":
216
210
  return [
217
211
  {
218
- html: `<hr style="border: none; border-top: 1px solid #bbb; margin: ${SPACING[trailing]};">`,
212
+ html: `<hr ${styleClass(`hr-${trailing}`, "faint")}>`,
219
213
  text: "---",
220
214
  spacing: trailing,
221
215
  },
222
216
  ];
223
217
 
224
- case "heroImage":
218
+ case "heroImage": {
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.
221
+ const dimensions =
222
+ element.width !== undefined && element.height !== undefined
223
+ ? ` width="${element.width}" height="${element.height}"`
224
+ : "";
225
225
  return [
226
226
  {
227
- html: `<img src="${element.src}" alt="${escapeHtml(element.alt)}" style="max-width: 100%; margin: ${SPACING[trailing]};">`,
227
+ html: `<img src="${escapeHtml(element.src)}" alt="${escapeHtml(element.alt)}"${dimensions} ${styleClass(`hero-${trailing}`)}>`,
228
228
  text: element.alt,
229
229
  spacing: trailing,
230
230
  },
231
231
  ];
232
+ }
232
233
 
233
234
  default: {
234
235
  const _exhaustive: never = element;
@@ -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,7 +16,10 @@
13
16
  * character-for-character.
14
17
  */
15
18
 
16
- import { MONO, type Spacing } from "./constants";
19
+ import { BASE_STYLE, DARK_STYLE } from "./colors";
20
+ import type { Spacing } from "./constants";
21
+ import { MSO_CTA_STYLE } from "./cta";
22
+ import { inlineOf, inlineStyles } from "./styles";
17
23
 
18
24
  /**
19
25
  * One rendered line — both presentations of one thing the notification says,
@@ -30,17 +36,51 @@ export interface EmailLine {
30
36
  spacing: Spacing;
31
37
  }
32
38
 
33
- /** Wrap rendered lines in the shared `<!DOCTYPE>` monospace shell. */
39
+ /**
40
+ * Wrap rendered lines in the shared `<!DOCTYPE>` monospace shell.
41
+ *
42
+ * The two metas declare that this email renders in either scheme, and `<style>`
43
+ * is what makes the declaration true — see `./colors.ts`'s `DARK_STYLE`. The
44
+ * declaration is a promise: made without the stylesheet, Apple Mail would stop
45
+ * protecting our colours, apply its dark background, and leave the inline LIGHT
46
+ * text colour sitting on top of it. The two ship together or not at all.
47
+ *
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.
58
+ *
59
+ * The `<!--[if mso]>` block is the mirror image: it is read ONLY by the Outlook
60
+ * Word engine, and undoes the one layout that engine cannot render — a linked
61
+ * CTA's full-box click target. See `./cta.ts`'s `MSO_CTA_STYLE`.
62
+ */
34
63
  export function htmlShell(lines: EmailLine[]): string {
35
- const inner = lines
36
- .map((line) => line.html)
37
- .filter(Boolean)
38
- .join("\n");
64
+ // `inlineStyles` is spent on the ELEMENT markup only. The shell states its own
65
+ // two slots directly: they are not `EmailLine`s, and keeping the inliner away
66
+ // from the `<head>` means a stylesheet can never be rewritten by it — CSS has
67
+ // attribute selectors (`[class="x"]`), and today's blocks happen not to use one.
68
+ const inner = inlineStyles(
69
+ lines
70
+ .map((line) => line.html)
71
+ .filter(Boolean)
72
+ .join("\n"),
73
+ );
39
74
  return `<!DOCTYPE html>
40
75
  <html lang="en">
41
- <head><meta charset="UTF-8"></head>
42
- <body style="${MONO} color: #1a1a1a; margin: 0; padding: 0;">
43
- <div style="max-width: 520px; margin: 0 auto;">
76
+ <head><meta charset="UTF-8">
77
+ <meta name="color-scheme" content="light dark">
78
+ <meta name="supported-color-schemes" content="light dark">
79
+ <style>${BASE_STYLE}
80
+ ${DARK_STYLE}</style>
81
+ <!--[if mso]><style>${MSO_CTA_STYLE}</style><![endif]--></head>
82
+ <body style="${inlineOf("body")}">
83
+ <div style="${inlineOf("frame")}">
44
84
  ${inner}
45
85
  </div>
46
86
  </body>
@@ -0,0 +1,319 @@
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. 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.
263
+ */
264
+ const RECIPE_PREFIX = "csr-";
265
+
266
+ /** `bubble-user` → `csr-bubble-user`. Derived, never hand-typed. */
267
+ function recipeClass(name: StyleName): string {
268
+ return `${RECIPE_PREFIX}${name}`;
269
+ }
270
+
271
+ /**
272
+ * The class attribute for one slot: its roles, then its recipe.
273
+ *
274
+ * Roles come FIRST so that dropping the recipe leaves exactly the class attribute
275
+ * the html surface shipped before this registry existed — which is what makes
276
+ * `inlineStyles` a byte-for-byte identity rather than a re-render.
277
+ */
278
+ export function styleClass(name: StyleName, ...roles: Role[]): string {
279
+ const classes = [...roles.map(classNameFor), recipeClass(name)];
280
+ return `class="${classes.join(" ")}"`;
281
+ }
282
+
283
+ /** `border-radius: 8px; padding: 10px 14px;` — one recipe, inline. */
284
+ export function inlineOf(name: StyleName): string {
285
+ return `${RECIPES[name].join("; ")};`;
286
+ }
287
+
288
+ /**
289
+ * Class-only markup → the html surface's markup.
290
+ *
291
+ * It never looks for elements; it rewrites CLASS ATTRIBUTES. Recipe classes become
292
+ * an inline `style`, role classes survive untouched, and a class attribute left
293
+ * with nothing in it disappears — which is what collapses `<p class="csr-p-normal">`
294
+ * back to `<p style="…">` rather than leaving a `class=""` behind.
295
+ *
296
+ * **Why a regex is safe here, and only here.** We control every class this can
297
+ * match, and `./escape-html.ts` escapes `"` — so no escaped content can close an
298
+ * attribute and forge one. The two values that do NOT pass through `escapeHtml`
299
+ * would be the hole, which is why `./cta.ts` and `./render.ts` escape `href` and
300
+ * `src` before interpolating: without that, a URL containing `" class="csr-body`
301
+ * is an HTML injection today and a style injection here.
302
+ */
303
+ export function inlineStyles(html: string): string {
304
+ return html.replace(/class="([^"]*)"/g, (_match, value: string) => {
305
+ const names = value.split(" ").filter(Boolean);
306
+ const hooks = names.filter((name) => !name.startsWith(RECIPE_PREFIX));
307
+ const decls = names
308
+ .filter((name) => name.startsWith(RECIPE_PREFIX))
309
+ .flatMap(
310
+ (name) => RECIPES[name.slice(RECIPE_PREFIX.length) as StyleName],
311
+ );
312
+ return [
313
+ hooks.length ? `class="${hooks.join(" ")}"` : "",
314
+ decls.length ? `style="${decls.join("; ")};"` : "",
315
+ ]
316
+ .filter(Boolean)
317
+ .join(" ");
318
+ });
319
+ }