@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
@@ -8,17 +8,18 @@
8
8
  * the "⋮" dots.
9
9
  *
10
10
  * INVARIANTS:
11
- * - Pure, and byte-identical to what the deleted `src/email/render/blocks` emits. The
12
- * box art is real output: the output-parity golden asserts every column of it.
11
+ * - Pure. The box art is real output, and every column of it is locked by
12
+ * `../../__tests__/render-snapshot.test.ts` a stray space is a visibly
13
+ * broken email, not a whitespace nit.
13
14
  * - Both surfaces truncate at the same point — `clampMessage` is the one
14
15
  * truncation authority, and HTML and plain text both run content through it.
15
16
  */
16
17
 
17
18
  import type { CallToAction, ChatTurn, ChatUnitItem } from "../../content";
18
19
 
19
- import { FONT_SIZE, MONO } from "./constants";
20
20
  import { ctaButton } from "./cta";
21
21
  import { escapeHtml } from "./escape-html";
22
+ import { styleClass } from "./styles";
22
23
 
23
24
  /** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
24
25
  function wrapText(text: string, width: number): string[] {
@@ -52,6 +53,17 @@ const MESSAGE_WIDTH = 36;
52
53
  * user box aligns with the assistant box. */
53
54
  const CHAT_INDENT = " ";
54
55
 
56
+ /**
57
+ * The gap under a chat row: 24px clears the bubbles, 16px hugs whatever the row
58
+ * is introducing (a CTA, the dots).
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.
64
+ */
65
+ type ChatRow = "chat-row-16" | "chat-row-24";
66
+
55
67
  /** Word-wrap `text`, then clamp to `maxLines`, ellipsizing the last line on overflow. */
56
68
  function wrapClamped(text: string, width: number, maxLines: number): string[] {
57
69
  const lines = wrapText(text, width);
@@ -76,8 +88,7 @@ function clampMessage(text: string): string {
76
88
  /** The `<hr>` bracketing a chat unit — 24px toward the bubbles, 12px on the
77
89
  * outer side. */
78
90
  function chatRuleHtml(position: "top" | "bottom"): string {
79
- const margin = position === "top" ? "12px 0 24px 0" : "24px 0 12px 0";
80
- return `<hr style="border: none; border-top: 1px solid #bbb; margin: ${margin};">`;
91
+ return `<hr ${styleClass(`chat-rule-${position}`, "faint")}>`;
81
92
  }
82
93
 
83
94
  /**
@@ -89,34 +100,35 @@ function chatRuleHtml(position: "top" | "bottom"): string {
89
100
  */
90
101
  function renderBubble(
91
102
  turn: ChatTurn,
92
- margin: string,
103
+ row: ChatRow,
93
104
  ): { html: string; text: string[] } {
94
105
  const clamped = clampMessage(turn.text);
95
106
  const isUser = turn.role === "user";
96
107
 
97
- const radius = isUser ? "8px 8px 0 8px" : "8px 8px 8px 0";
98
- const bubbleAlign = isUser ? " text-align: right;" : "";
99
- const cellAlign = isUser ? "right" : "left";
100
- const csHidden = isUser ? "visibility: hidden; " : "";
101
- const kaomojiHidden = isUser ? "" : "visibility: hidden; ";
108
+ // Each side draws its own avatar and hides the other's, which is what reserves
109
+ // both columns and keeps the two bubbles aligned.
110
+ const bubble = isUser ? "bubble-user" : "bubble-assistant";
111
+ const channel = isUser ? "chat-channel-right" : "chat-channel-left";
112
+ const avatar = isUser ? "chat-avatar-left-hidden" : "chat-avatar-left";
113
+ const kaomoji = isUser ? "chat-avatar-right" : "chat-avatar-right-hidden";
102
114
 
103
115
  const attributionRow =
104
116
  isUser && turn.from
105
117
  ? `
106
118
  <tr>
107
119
  <td></td>
108
- <td style="${MONO} font-size: ${FONT_SIZE}; color: #666; text-align: right; padding-top: 6px; padding-right: 1ch;">${escapeHtml(turn.from)}</td>
120
+ <td ${styleClass("chat-attribution", "meta")}>${escapeHtml(turn.from)}</td>
109
121
  <td></td>
110
122
  </tr>`
111
123
  : "";
112
124
 
113
- const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: ${margin};">
125
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" ${styleClass(row)}>
114
126
  <tr>
115
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; ${csHidden}white-space: nowrap;">[c_S]</td>
116
- <td style="width: 100%; text-align: ${cellAlign};"><table cellpadding="0" cellspacing="0" border="0" style="display: inline-block; max-width: 100%; vertical-align: bottom;">
117
- <tr><td style="border-radius: ${radius}; padding: 10px 14px;${bubbleAlign} ${MONO} font-size: ${FONT_SIZE}; color: #ffffff; background: #666;">${escapeHtml(clamped)}</td></tr>
127
+ <td ${styleClass(avatar)}>[c_S]</td>
128
+ <td ${styleClass(channel)}><table cellpadding="0" cellspacing="0" border="0" ${styleClass("chat-bubble-wrap")}>
129
+ <tr><td ${styleClass(bubble, "bubble")}>${escapeHtml(clamped)}</td></tr>
118
130
  </table></td>
119
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; ${kaomojiHidden}white-space: nowrap;">(•̀_ರ╮)</td>
131
+ <td ${styleClass(kaomoji)}>(•̀_ರ╮)</td>
120
132
  </tr>${attributionRow}
121
133
  </table>`;
122
134
 
@@ -148,7 +160,7 @@ const CHAT_RIGHT_EDGE = CHAT_INDENT.length + MESSAGE_WIDTH + 4;
148
160
  /** Centered "⋮" HTML, sized to sit above and centered over a CTA box (they share
149
161
  * the same inline-block, so the dots span exactly the button's width). */
150
162
  function dotsOverCtaHtml(): string {
151
- return `<div style="${MONO} font-size: 20px; font-weight: bold; color: #666; text-align: center; margin: 0 0 16px 0;">⋮</div>`;
163
+ return `<div ${styleClass("dots-over-cta", "dots")}>⋮</div>`;
152
164
  }
153
165
 
154
166
  /**
@@ -163,15 +175,15 @@ function renderChatCta(
163
175
  align: "left" | "right",
164
176
  withDots: boolean,
165
177
  ): { html: string; text: string[] } {
166
- const { html: btnHtml, text: btnText } = ctaButton(cta, "0");
178
+ const { html: btnHtml, text: btnText } = ctaButton(cta, "none");
167
179
  // Dots + button share one inline-block so the dots center over the button's
168
180
  // exact width regardless of label length.
169
- const stack = `<div style="display: inline-block; text-align: left;">${withDots ? dotsOverCtaHtml() : ""}${btnHtml}</div>`;
170
- const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 24px 0;">
181
+ const stack = `<div ${styleClass("cta-stack")}>${withDots ? dotsOverCtaHtml() : ""}${btnHtml}</div>`;
182
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" ${styleClass("chat-row-24")}>
171
183
  <tr>
172
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
173
- <td style="width: 100%; text-align: ${align};">${stack}</td>
174
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
184
+ <td ${styleClass("chat-avatar-left-hidden")}>[c_S]</td>
185
+ <td ${styleClass(`chat-channel-${align}`)}>${stack}</td>
186
+ <td ${styleClass("chat-avatar-right-hidden")}>(•̀_ರ╮)</td>
175
187
  </tr>
176
188
  </table>`;
177
189
 
@@ -203,11 +215,11 @@ function renderChatCta(
203
215
  * case folds the dots into the CTA via `renderChatCta`, centered over the box).
204
216
  */
205
217
  function renderChatDots(): { html: string; text: string[] } {
206
- const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 16px 0;">
218
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" ${styleClass("chat-row-16")}>
207
219
  <tr>
208
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
209
- <td style="width: 100%; text-align: center; ${MONO} font-size: 20px; font-weight: bold; color: #666;">⋮</td>
210
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
220
+ <td ${styleClass("chat-avatar-left-hidden")}>[c_S]</td>
221
+ <td ${styleClass("dots-cell", "dots")}>⋮</td>
222
+ <td ${styleClass("chat-avatar-right-hidden")}>(•̀_ರ╮)</td>
211
223
  </tr>
212
224
  </table>`;
213
225
  // Center the "⋮" over the message box (avatar gutter + box width + borders).
@@ -250,11 +262,11 @@ export function renderChatUnit(items: ChatUnitItem[]): {
250
262
  // A bubble directly above a CTA or continuation dots gets a tighter 16px
251
263
  // gap; else 24px.
252
264
  const next = items[i + 1]?.type;
253
- const margin =
265
+ const row: ChatRow =
254
266
  next === "callToAction" || next === "continuation"
255
- ? "0 0 16px 0"
256
- : "0 0 24px 0";
257
- parts.push(renderBubble(item, margin));
267
+ ? "chat-row-16"
268
+ : "chat-row-24";
269
+ parts.push(renderBubble(item, row));
258
270
  });
259
271
 
260
272
  const width = parts
@@ -0,0 +1,342 @@
1
+ /**
2
+ * The email channel's colour — every colour it emits, in every scheme it
3
+ * supports (ADR-CONTRACTS-088).
4
+ *
5
+ * This is the one place a colour changes. Everything else in this directory asks
6
+ * for a ROLE and gets whatever answer the scheme has; no module below emits a
7
+ * literal, and `__tests__/colors.test.ts` fails if one creeps back in.
8
+ *
9
+ * **Why this is email's and not the notification layer's.** Email is the only
10
+ * channel that hand-authors colour, because it is the only one with no
11
+ * client-side semantic vocabulary to lean on. `../slack` says `:warning:` and
12
+ * `context` and lets Slack's client theme it — Block Kit exposes no author-set
13
+ * colour at all, so Slack's dark mode costs us nothing. `../sms` has no colour to
14
+ * have. A shared `notifications/colors.ts` would look DRY and would put a
15
+ * channel's presentation back inside the channel-agnostic layer, which is the
16
+ * coupling `../../renderer.ts` exists to prevent. A channel that needs colour
17
+ * gets its own file, next to its own renderer.
18
+ *
19
+ * **Roles are meaning, not coincidence** — and this file has already been paid for
20
+ * saying so. `border`, `bubble`, `meta` and `dots` all answered `#666`, and a
21
+ * palette keyed by colour would have made them one entry. Then `border` alone
22
+ * moved to the link's blue, which under that shortcut would have dragged every
23
+ * chat bubble with it.
24
+ *
25
+ * The test is whether two things are two DECISIONS, not whether they currently
26
+ * agree — and it cuts both ways. `faint` and `rule` were split on that reasoning
27
+ * and it was wrong: the `/* EOM *\/` marker and the rules are one decision, "the
28
+ * marks that recede", wearing two names. They were merged. A role that can never
29
+ * be answered separately is not a role, it is a synonym, and synonyms cost the
30
+ * same as roles while buying nothing.
31
+ *
32
+ * **What tracks the app, and what does not.** `./constants.ts` explains that a
33
+ * copied token cannot follow the app, because contracts sits above it in the
34
+ * dependency flow — "that divergence is the cost of the arrow pointing one way".
35
+ * That cost is worth paying for the colours a recipient reads as OURS, and not
36
+ * for the rest:
37
+ *
38
+ * - `link`, `border` and `destructive` are copied, and named below with the token
39
+ * they came from. A link is the brand's blue in a light email for the same
40
+ * reason it is in the app, and a CTA's edge is that blue because the box IS the
41
+ * link.
42
+ * - Every other role is this channel's own. Its look — monospace, rules, box art —
43
+ * is not the app's, and inventing an app token to copy for `bubble` or `dots`
44
+ * would be debt bought for nothing.
45
+ *
46
+ * INVARIANTS:
47
+ * - Pure data and pure functions of it. No clock, no environment, no I/O.
48
+ * - Nothing here is exported from the domain barrel. A colour is not vocabulary,
49
+ * for the same reason `./constants.ts`'s margins are not.
50
+ */
51
+
52
+ /**
53
+ * A scheme the channel can render. Two today; the shape is a set rather than a
54
+ * boolean because `highContrast` or `print` would join it rather than replace it.
55
+ */
56
+ export type ColorScheme = "light" | "dark";
57
+
58
+ /**
59
+ * Every colour decision this channel makes, named by what it means.
60
+ *
61
+ * `faint` is the marks that recede — the `/* EOM *\/` marker AND every rule. One
62
+ * decision, so one role, even though it lands on `color` in one place and
63
+ * `border-top-color` in the other. Which CSS property carries a colour is not
64
+ * what makes it a different colour.
65
+ */
66
+ export type Role =
67
+ | "background"
68
+ | "text"
69
+ | "faint"
70
+ | "meta"
71
+ | "cta"
72
+ | "ctaHover"
73
+ | "bubble"
74
+ | "onBubble"
75
+ | "dots"
76
+ | "link"
77
+ | "destructive";
78
+
79
+ /** One scheme's answer for every role. `Record` — a scheme owes an answer to all of them. */
80
+ export type EmailPalette = Record<Role, string>;
81
+
82
+ /** Every role, for tests to iterate. Source order is the table in ADR-CONTRACTS-088. */
83
+ export const ROLES: readonly Role[] = [
84
+ "background",
85
+ "text",
86
+ "faint",
87
+ "meta",
88
+ "cta",
89
+ "ctaHover",
90
+ "bubble",
91
+ "onBubble",
92
+ "dots",
93
+ "link",
94
+ "destructive",
95
+ ];
96
+
97
+ /**
98
+ * The roles carried by a class hook, and therefore needing a rule in
99
+ * `DARK_STYLE`. The absentees are not exceptions to tune — they are structural,
100
+ * each riding another role's rule because it can never move alone:
101
+ *
102
+ * - `background` and `text` land on `body`, which needs no class.
103
+ * - `onBubble` rides `.cs-bubble` — a bubble's fill and its text change together
104
+ * or not at all.
105
+ */
106
+ export const CLASS_ROLES: readonly Role[] = [
107
+ "faint",
108
+ "meta",
109
+ "cta",
110
+ "ctaHover",
111
+ "bubble",
112
+ "dots",
113
+ "link",
114
+ "destructive",
115
+ ];
116
+
117
+ /**
118
+ * Roles whose rule is a `:hover` state rather than a resting colour.
119
+ *
120
+ * Two things follow, and both are departures from every other role:
121
+ *
122
+ * - **It cannot be inline.** `:hover` has no inline form, so BOTH schemes state it
123
+ * in the stylesheet — light in `BASE_STYLE`, dark overriding in `DARK_STYLE`.
124
+ * Every other role is inline-light with a dark override.
125
+ * - **It is opportunistic.** `:hover` support in mail clients is poor and
126
+ * inconsistent: good in Apple Mail, partial in new Outlook for Mac and Yahoo,
127
+ * absent in Gmail and classic Outlook. Where it does not work the button simply
128
+ * rests, which is the state it has today — so this can only add, never subtract.
129
+ */
130
+ export const HOVER_ROLES: readonly Role[] = ["ctaHover"];
131
+
132
+ /**
133
+ * The app's `--primary`, per scheme — resolved with `pnpm color`, never eyeballed.
134
+ *
135
+ * TWO roles spend this: `link` and `cta`. A call to action is a link wearing a
136
+ * box, so its edge and its label are the link's colour and say which of these it
137
+ * is. They stay separate roles rather than collapsing into one, because "the CTA
138
+ * tracks the link" is a decision this states rather than a fact about colour —
139
+ * pull them apart by giving `cta` its own value, not by unpicking a shared
140
+ * constant.
141
+ */
142
+ const PRIMARY: Record<ColorScheme, string> = {
143
+ // `:root --primary` (`oklch(0.5249 0.264881 263.0129)` → `#0050ff`). Left at the
144
+ // value the old layer shipped: deltaEOK to the token is 0.0167, inside the 0.02
145
+ // JND, so they are the same blue to a reader and "fixing" it would rewrite 25
146
+ // snapshots to change nothing anyone can see.
147
+ light: "#0047FF",
148
+ // `.dark --primary` (`oklch(0.91 0.165 195)`), gamut-mapped to sRGB. The token
149
+ // sits just outside sRGB and maps to aqua — `oklch(#00ffff)` is
150
+ // `L=0.9054 C=0.1546 H=194.77` against the token's `0.91 / 0.165 / 195` — so
151
+ // this is the token, not a clamping artifact.
152
+ dark: "#00ffff",
153
+ };
154
+
155
+ const PALETTES: Record<ColorScheme, EmailPalette> = {
156
+ light: {
157
+ background: "#ffffff",
158
+ text: "#1a1a1a",
159
+ faint: "#bbb",
160
+ meta: "#666",
161
+ cta: PRIMARY.light,
162
+ // The app's `:root --secondary-item-hover` — `--primary` at 20%,
163
+ // `oklch(0.9064 0.1527 194.8 / 20%)` — flattened over this scheme's
164
+ // `background`, because an email cannot lean on alpha:
165
+ // pnpm color 'oklch(0.9064 0.1527 194.8 / 20%)' --over '#ffffff'
166
+ // A function of `background`; recompute if that moves.
167
+ ctaHover: "#d1ffff",
168
+ bubble: "#666",
169
+ onBubble: "#ffffff",
170
+ dots: "#666",
171
+ link: PRIMARY.light,
172
+ // The app's `:root --destructive` (`oklch(0.577 0.245 27.325)`) in sRGB.
173
+ destructive: "#e7000b",
174
+ },
175
+ dark: {
176
+ background: "#1a1a1a",
177
+ text: "#e8e8e8",
178
+ faint: "#666",
179
+ meta: "#8a8a8a",
180
+ cta: PRIMARY.dark,
181
+ // Same token and same arithmetic as `bubble` below, and the identical answer
182
+ // — both are `.dark --secondary-item-hover` over this `background`. Two roles
183
+ // because they are two decisions: a hovered button and a chat bubble have no
184
+ // reason to move together, and the day one of them changes, keying this
185
+ // palette by colour would have moved both.
186
+ ctaHover: "#154848",
187
+ // The app's `.dark --secondary-item-hover` (the tree-nav hover), which is
188
+ // `--primary` at 20% — `oklch(0.91 0.165 195 / 20%)` — FLATTENED over this
189
+ // scheme's `background`, because an email cannot lean on alpha:
190
+ // pnpm color 'oklch(0.91 0.165 195 / 20%)' --over '#1a1a1a'
191
+ // It is a function of `background`, so recompute it if that moves. Hue
192
+ // survives the flattening (195.10), which is what makes it read as the same
193
+ // hover rather than a grey.
194
+ bubble: "#154848",
195
+ onBubble: "#ffffff",
196
+ dots: "#8a8a8a",
197
+ link: PRIMARY.dark,
198
+ destructive: "#ff6b6b",
199
+ },
200
+ };
201
+
202
+ /**
203
+ * One scheme's palette.
204
+ *
205
+ * Callers ask for a scheme rather than importing a `LIGHT` / `DARK` object, so a
206
+ * third scheme is an entry in `PALETTES` rather than an edit to every call site.
207
+ */
208
+ export function palette(scheme: ColorScheme): EmailPalette {
209
+ return PALETTES[scheme];
210
+ }
211
+
212
+ /** The CSS properties a role's colour lands on. */
213
+ export type ColorProp =
214
+ | "color"
215
+ | "background"
216
+ | "background-color"
217
+ | "border-color"
218
+ | "border-top-color";
219
+
220
+ /**
221
+ * `onBubble` → `cs-on-bubble`. Derived, never hand-typed.
222
+ *
223
+ * The `cs-` prefix is load-bearing, and `./styles.ts` explains why: a role class
224
+ * survives into the shipped HTML because `DARK_STYLE` needs it there, while a
225
+ * `csr-` recipe class is consumed by the inliner and never ships. The prefixes
226
+ * are what make "a hook" and "a bundle of declarations" mechanically separable.
227
+ */
228
+ export function classNameFor(role: Role): string {
229
+ return `cs-${role.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
230
+ }
231
+
232
+ /**
233
+ * A class attribute for one or more roles, e.g. `class="cs-cta cs-cta-hover"`.
234
+ *
235
+ * Each class is DERIVED from its role, which is what makes a class that names one
236
+ * role while carrying another's colour unrepresentable rather than merely
237
+ * tested.
238
+ *
239
+ * Several roles land on one element when they are separately decidable but
240
+ * inseparably placed — a CTA's edge and its hover fill are both the box, and the
241
+ * hover one is present only when the box is actually clickable.
242
+ */
243
+ export function roleClass(...roles: Role[]): string {
244
+ return `class="${roles.map(classNameFor).join(" ")}"`;
245
+ }
246
+
247
+ /**
248
+ * A role's LIGHT declaration, unterminated: `color: #666`.
249
+ *
250
+ * Light is the baseline every client gets, including the ones that strip
251
+ * `<style>`. Dark arrives via `darkStyle` instead — see it for why the two are
252
+ * not two sources of truth.
253
+ *
254
+ * Unterminated because a declaration is an ingredient: `./styles.ts` composes it
255
+ * into an ordered recipe and owns the separators, and that recipe is what BOTH
256
+ * surfaces are built from — inline for HTML, a rule for AMP. See `./constants.ts`'s
257
+ * `MONO_DECL`, which is unterminated for the same reason.
258
+ */
259
+ export function roleDecl(role: Role, prop: ColorProp = "color"): string {
260
+ return `${prop}: ${PALETTES.light[role]}`;
261
+ }
262
+
263
+ const light = PALETTES.light;
264
+ const dark = PALETTES.dark;
265
+
266
+ /**
267
+ * The scheme-independent rules — everything that has no inline form.
268
+ *
269
+ * Only `:hover` lives here, and only because it cannot be inline. This block is
270
+ * the LIGHT hover; the dark block follows it in the shell and overrides it, so
271
+ * source order is what resolves them (same specificity).
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.
279
+ *
280
+ * `.cs-cta-hover` is present ONLY on a CTA that actually has an `href` — see
281
+ * `./cta.ts`. An OTP code wears `.cs-cta` for its edge and never this, because a
282
+ * hover state on something unclickable promises a click that will not happen.
283
+ */
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);
291
+
292
+ /**
293
+ * The dark override.
294
+ *
295
+ * A template rather than a generated string — it reads top to bottom as the CSS
296
+ * it is — but it interpolates `palette("dark")` rather than literals, so a
297
+ * colour still changes in exactly one place. What a template CAN do is forget a
298
+ * role; `__tests__/colors.test.ts` is what catches that.
299
+ *
300
+ * `!important` on every declaration is load-bearing, not defensive: the light
301
+ * colour is inline, inline beats a stylesheet on specificity, and without
302
+ * `!important` every rule here silently loses and dark quietly does nothing.
303
+ *
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.
313
+ *
314
+ * `.cs-faint` states both `color` and `border-top-color` because one role lands
315
+ * on two properties — the `/* EOM *\/` span takes the first and ignores the
316
+ * second, each `<hr>` does the reverse. Two classes would be tidier to read and
317
+ * would mean the class no longer derives from the role, which is the property
318
+ * that makes a class carrying the wrong colour unrepresentable. A declaration an
319
+ * element ignores costs nothing; a class that can lie costs a debugging session.
320
+ *
321
+ * This block is additive and fail-safe. A client that strips `<style>` or ignores
322
+ * `prefers-color-scheme` keeps the inline light colours — which is exactly
323
+ * today's behaviour, not a degraded one.
324
+ *
325
+ * Custom properties would collapse all of this into one `:root` redefinition and
326
+ * are NOT used: caniemail puts them at ~45% support, and Outlook for Mac 16.80 —
327
+ * a current client, and one of the three that honours `prefers-color-scheme` at
328
+ * all — does not support them. In a client without them `color: var(--text, #1a1a1a)`
329
+ * is an invalid declaration and drops whole, so text loses its colour rather than
330
+ * falling back. They would break dark mode in a client we are building it for.
331
+ */
332
+ export const DARK_STYLE = `@media (prefers-color-scheme: dark) {
333
+ body { background-color: ${dark.background} !important; color: ${dark.text} !important; }
334
+ .cs-faint { color: ${dark.faint} !important; border-top-color: ${dark.faint} !important; }
335
+ .cs-meta { color: ${dark.meta} !important; }
336
+ .cs-cta { border-color: ${dark.cta} !important; }
337
+ .cs-cta-hover:hover { background-color: ${dark.ctaHover} !important; }
338
+ .cs-bubble { background: ${dark.bubble} !important; color: ${dark.onBubble} !important; }
339
+ .cs-dots { color: ${dark.dots} !important; }
340
+ .cs-link { color: ${dark.link} !important; }
341
+ .cs-destructive { color: ${dark.destructive} !important; }
342
+ }`;
@@ -1,8 +1,12 @@
1
1
  /**
2
- * Email channel constants — the styling vocabulary and the one link in it
2
+ * Email channel constants — typography, layout, and the one link in them
3
3
  * (ADR-CONTRACTS-086). `EMAIL_FROM` (SES envelope) is infra and stays in the
4
4
  * backend.
5
5
  *
6
+ * Colour is NOT here: it moved to `./colors.ts` when the channel gained a second
7
+ * scheme (ADR-CONTRACTS-088). What is left is what has only ever had one answer —
8
+ * a font stack does not change in the dark.
9
+ *
6
10
  * These are the email channel's own answer and are deliberately NOT exported
7
11
  * from the domain barrel: a margin is not vocabulary. The brand NAME is not here
8
12
  * either — it is `../../context`'s `COMPANY_NAME`, because it is the same answer
@@ -24,8 +28,16 @@ export const COMPANY_URL = "https://companysemantics.ai";
24
28
  // Styling
25
29
  // =============================================================================
26
30
 
27
- /** The `font-family` declaration every element carries. */
28
- export const MONO = `font-family: ${MONO_FONT_STACK};`;
31
+ /**
32
+ * The `font-family` declaration every element carries, WITHOUT its semicolon.
33
+ *
34
+ * Declarations are stored unterminated because `./styles.ts` composes them into
35
+ * an ordered list and puts the separators back — one `; ` between, one `;` at the
36
+ * end — for both surfaces. A declaration that carried its own terminator would
37
+ * make the joiner's output depend on which declaration it happened to be, which
38
+ * is exactly the drift the registry exists to prevent.
39
+ */
40
+ export const MONO_DECL = `font-family: ${MONO_FONT_STACK}`;
29
41
 
30
42
  /**
31
43
  * The single font size for every email element (HTML). Plain text is monospace
@@ -45,3 +57,15 @@ export const SPACING: Record<Spacing, string> = {
45
57
  tight: "0 0 4px 0",
46
58
  none: "0",
47
59
  };
60
+
61
+ /**
62
+ * A CTA button's inner padding.
63
+ *
64
+ * It lands in three places that MUST agree, or Outlook double-pads and the box
65
+ * collapses: `./styles.ts`'s `cta-anchor` (linked — the padding is what makes the
66
+ * whole box clickable), its `cta-cell-code` (unlinked), and `./cta.ts`'s
67
+ * `MSO_CTA_STYLE` (Outlook, which cannot do the first). It sits here rather than
68
+ * in `./cta.ts` because `./styles.ts` needs it and `./cta.ts` imports FROM
69
+ * `./styles.ts` — a constant shared by both belongs below both.
70
+ */
71
+ export const CTA_PAD = "16px 24px";
@@ -7,19 +7,48 @@
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
+ * 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`.
48
+ */
49
+ export const MSO_CTA_STYLE = `.cs-cta td { padding: ${CTA_PAD} !important; }
50
+ .cs-cta a { padding: 0 !important; }`;
51
+
23
52
  /** `>> LABEL <<` ASCII box (`*` corners): 3 lines, sized to the label. */
24
53
  function asciiCtaBox(label: string): string {
25
54
  const pad = " ".repeat(CTA_BOX_PAD);
@@ -35,24 +64,45 @@ function asciiCtaBox(label: string): string {
35
64
  */
36
65
  export function ctaButton(
37
66
  cta: CallToAction,
38
- margin: string,
67
+ margin: Spacing,
39
68
  ): { html: string; text: string } {
40
69
  const { label, href } = cta;
41
70
 
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};`;
71
+ // The hover hook goes on ONLY when there is somewhere to go. An OTP code is a
72
+ // 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).
77
+ const boxClass = href
78
+ ? styleClass(`cta-box-${margin}`, "cta", "ctaHover")
79
+ : styleClass(`cta-box-${margin}`, "cta");
80
+ // A linked button carries its padding on the anchor (below), so the WHOLE box is
81
+ // the click target; the cell then holds none. An unlinked code is not clickable,
82
+ // so its padding stays on the cell and the label is a bare `<span>` — today's
83
+ // box exactly. Outlook ignores the anchor's `display: block` and is corrected by
84
+ // `MSO_CTA_STYLE`, which puts this same padding back on the cell there.
85
+ const cellClass = href
86
+ ? styleClass("cta-cell-linked")
87
+ : styleClass("cta-cell-code");
44
88
 
45
89
  // Underline only the label text (not the chevrons/spaces), and only when linked.
46
90
  const labelHtml = href
47
- ? `<span style="text-decoration: underline;">${escapeHtml(label)}</span>`
91
+ ? `<span ${styleClass("cta-label")}>${escapeHtml(label)}</span>`
48
92
  : escapeHtml(label);
49
93
  const chevrons = `&gt;&gt; ${labelHtml} &lt;&lt;`;
94
+ // Both wear the `link` colour — a call to action looks like one whether or not
95
+ // it goes anywhere, and the box's edge is already that colour. What separates
96
+ // them is the UNDERLINE above and the `<a>` itself: an unlinked label is a
97
+ // `<span>`, so an OTP code is still not clickable and still prints no URL. The
98
+ // linked anchor fills the box (`display: block` + the padding) so a click
99
+ // anywhere in it counts.
50
100
  const inner = href
51
- ? `<a href="${href}" style="color: #0047FF; text-decoration: none;">${chevrons}</a>`
52
- : chevrons;
101
+ ? `<a href="${escapeHtml(href)}" ${styleClass("cta-anchor", "link")}>${chevrons}</a>`
102
+ : `<span ${styleClass("cta-code", "link")}>${chevrons}</span>`;
53
103
 
54
- const html = `<table cellpadding="0" cellspacing="0" border="0" style="${tableStyle}">
55
- <tr><td style="${tdStyle}">
104
+ const html = `<table cellpadding="0" cellspacing="0" border="0" ${boxClass}>
105
+ <tr><td ${cellClass}>
56
106
  ${inner}
57
107
  </td></tr>
58
108
  </table>`;