@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.
@@ -17,9 +17,9 @@
17
17
 
18
18
  import type { CallToAction, ChatTurn, ChatUnitItem } from "../../content";
19
19
 
20
- import { FONT_SIZE, MONO } from "./constants";
21
20
  import { ctaButton } from "./cta";
22
21
  import { escapeHtml } from "./escape-html";
22
+ import { styleClass } from "./styles";
23
23
 
24
24
  /** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
25
25
  function wrapText(text: string, width: number): string[] {
@@ -53,6 +53,18 @@ const MESSAGE_WIDTH = 36;
53
53
  * user box aligns with the assistant box. */
54
54
  const CHAT_INDENT = " ";
55
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. The AMP surface first forced this —
61
+ * a margin could not be inline there — and it earns its keep without that surface
62
+ * (ADR-CONTRACTS-091): naming the two the layout actually has is what makes a third
63
+ * one a deliberate addition to `./styles.ts` rather than a new string appearing at
64
+ * a call site.
65
+ */
66
+ type ChatRow = "chat-row-16" | "chat-row-24";
67
+
56
68
  /** Word-wrap `text`, then clamp to `maxLines`, ellipsizing the last line on overflow. */
57
69
  function wrapClamped(text: string, width: number, maxLines: number): string[] {
58
70
  const lines = wrapText(text, width);
@@ -77,8 +89,7 @@ function clampMessage(text: string): string {
77
89
  /** The `<hr>` bracketing a chat unit — 24px toward the bubbles, 12px on the
78
90
  * outer side. */
79
91
  function chatRuleHtml(position: "top" | "bottom"): string {
80
- const margin = position === "top" ? "12px 0 24px 0" : "24px 0 12px 0";
81
- return `<hr style="border: none; border-top: 1px solid #bbb; margin: ${margin};">`;
92
+ return `<hr ${styleClass(`chat-rule-${position}`, "faint")}>`;
82
93
  }
83
94
 
84
95
  /**
@@ -90,34 +101,35 @@ function chatRuleHtml(position: "top" | "bottom"): string {
90
101
  */
91
102
  function renderBubble(
92
103
  turn: ChatTurn,
93
- margin: string,
104
+ row: ChatRow,
94
105
  ): { html: string; text: string[] } {
95
106
  const clamped = clampMessage(turn.text);
96
107
  const isUser = turn.role === "user";
97
108
 
98
- const radius = isUser ? "8px 8px 0 8px" : "8px 8px 8px 0";
99
- const bubbleAlign = isUser ? " text-align: right;" : "";
100
- const cellAlign = isUser ? "right" : "left";
101
- const csHidden = isUser ? "visibility: hidden; " : "";
102
- const kaomojiHidden = isUser ? "" : "visibility: hidden; ";
109
+ // Each side draws its own avatar and hides the other's, which is what reserves
110
+ // both columns and keeps the two bubbles aligned.
111
+ const bubble = isUser ? "bubble-user" : "bubble-assistant";
112
+ const channel = isUser ? "chat-channel-right" : "chat-channel-left";
113
+ const avatar = isUser ? "chat-avatar-left-hidden" : "chat-avatar-left";
114
+ const kaomoji = isUser ? "chat-avatar-right" : "chat-avatar-right-hidden";
103
115
 
104
116
  const attributionRow =
105
117
  isUser && turn.from
106
118
  ? `
107
119
  <tr>
108
120
  <td></td>
109
- <td style="${MONO} font-size: ${FONT_SIZE}; color: #666; text-align: right; padding-top: 6px; padding-right: 1ch;">${escapeHtml(turn.from)}</td>
121
+ <td ${styleClass("chat-attribution", "meta")}>${escapeHtml(turn.from)}</td>
110
122
  <td></td>
111
123
  </tr>`
112
124
  : "";
113
125
 
114
- const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: ${margin};">
126
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" ${styleClass(row)}>
115
127
  <tr>
116
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; ${csHidden}white-space: nowrap;">[c_S]</td>
117
- <td style="width: 100%; text-align: ${cellAlign};"><table cellpadding="0" cellspacing="0" border="0" style="display: inline-block; max-width: 100%; vertical-align: bottom;">
118
- <tr><td style="border-radius: ${radius}; padding: 10px 14px;${bubbleAlign} ${MONO} font-size: ${FONT_SIZE}; color: #ffffff; background: #666;">${escapeHtml(clamped)}</td></tr>
128
+ <td ${styleClass(avatar)}>[c_S]</td>
129
+ <td ${styleClass(channel)}><table cellpadding="0" cellspacing="0" border="0" ${styleClass("chat-bubble-wrap")}>
130
+ <tr><td ${styleClass(bubble, "bubble")}>${escapeHtml(clamped)}</td></tr>
119
131
  </table></td>
120
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; ${kaomojiHidden}white-space: nowrap;">(•̀_ರ╮)</td>
132
+ <td ${styleClass(kaomoji)}>(•̀_ರ╮)</td>
121
133
  </tr>${attributionRow}
122
134
  </table>`;
123
135
 
@@ -149,7 +161,7 @@ const CHAT_RIGHT_EDGE = CHAT_INDENT.length + MESSAGE_WIDTH + 4;
149
161
  /** Centered "⋮" HTML, sized to sit above and centered over a CTA box (they share
150
162
  * the same inline-block, so the dots span exactly the button's width). */
151
163
  function dotsOverCtaHtml(): string {
152
- return `<div style="${MONO} font-size: 20px; font-weight: bold; color: #666; text-align: center; margin: 0 0 16px 0;">⋮</div>`;
164
+ return `<div ${styleClass("dots-over-cta", "dots")}>⋮</div>`;
153
165
  }
154
166
 
155
167
  /**
@@ -164,15 +176,15 @@ function renderChatCta(
164
176
  align: "left" | "right",
165
177
  withDots: boolean,
166
178
  ): { html: string; text: string[] } {
167
- const { html: btnHtml, text: btnText } = ctaButton(cta, "0");
179
+ const { html: btnHtml, text: btnText } = ctaButton(cta, "none");
168
180
  // Dots + button share one inline-block so the dots center over the button's
169
181
  // exact width regardless of label length.
170
- const stack = `<div style="display: inline-block; text-align: left;">${withDots ? dotsOverCtaHtml() : ""}${btnHtml}</div>`;
171
- const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 24px 0;">
182
+ const stack = `<div ${styleClass("cta-stack")}>${withDots ? dotsOverCtaHtml() : ""}${btnHtml}</div>`;
183
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" ${styleClass("chat-row-24")}>
172
184
  <tr>
173
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
174
- <td style="width: 100%; text-align: ${align};">${stack}</td>
175
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
185
+ <td ${styleClass("chat-avatar-left-hidden")}>[c_S]</td>
186
+ <td ${styleClass(`chat-channel-${align}`)}>${stack}</td>
187
+ <td ${styleClass("chat-avatar-right-hidden")}>(•̀_ರ╮)</td>
176
188
  </tr>
177
189
  </table>`;
178
190
 
@@ -204,11 +216,11 @@ function renderChatCta(
204
216
  * case folds the dots into the CTA via `renderChatCta`, centered over the box).
205
217
  */
206
218
  function renderChatDots(): { html: string; text: string[] } {
207
- const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 16px 0;">
219
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" ${styleClass("chat-row-16")}>
208
220
  <tr>
209
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
210
- <td style="width: 100%; text-align: center; ${MONO} font-size: 20px; font-weight: bold; color: #666;">⋮</td>
211
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
221
+ <td ${styleClass("chat-avatar-left-hidden")}>[c_S]</td>
222
+ <td ${styleClass("dots-cell", "dots")}>⋮</td>
223
+ <td ${styleClass("chat-avatar-right-hidden")}>(•̀_ರ╮)</td>
212
224
  </tr>
213
225
  </table>`;
214
226
  // Center the "⋮" over the message box (avatar gutter + box width + borders).
@@ -251,11 +263,11 @@ export function renderChatUnit(items: ChatUnitItem[]): {
251
263
  // A bubble directly above a CTA or continuation dots gets a tighter 16px
252
264
  // gap; else 24px.
253
265
  const next = items[i + 1]?.type;
254
- const margin =
266
+ const row: ChatRow =
255
267
  next === "callToAction" || next === "continuation"
256
- ? "0 0 16px 0"
257
- : "0 0 24px 0";
258
- parts.push(renderBubble(item, margin));
268
+ ? "chat-row-16"
269
+ : "chat-row-24";
270
+ parts.push(renderBubble(item, row));
259
271
  });
260
272
 
261
273
  const width = parts
@@ -0,0 +1,345 @@
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. Apple Mail honours `:hover`, new
274
+ * Outlook for Mac and Yahoo partially, Gmail and classic Outlook not at all. There
275
+ * is no fallback to write: without it the button rests, which is how it looks
276
+ * today. Purely additive, like the dark path.
277
+ *
278
+ * Gmail never gets it, and that is now a settled trade rather than a gap. The AMP
279
+ * surface existed to reach exactly this one role in Gmail — and cost Gmail iOS
280
+ * readers their dark mode to do it, since Gmail leaves an AMP part un-inverted.
281
+ * A hover state is worth less than a legible email (ADR-CONTRACTS-091).
282
+ *
283
+ * `!important` is load-bearing: the light colour is inline, inline beats a
284
+ * stylesheet, and without it this rule silently loses. It used to be a parameter,
285
+ * because the AMP surface had no inline style to beat and AMP forbids the keyword.
286
+ * With that surface gone there is one caller, and a parameter with one caller is
287
+ * not a seam.
288
+ *
289
+ * `.cs-cta-hover` is present ONLY on a CTA that actually has an `href` — see
290
+ * `./cta.ts`. An OTP code wears `.cs-cta` for its edge and never this, because a
291
+ * hover state on something unclickable promises a click that will not happen.
292
+ */
293
+ export const BASE_STYLE = `.cs-cta-hover:hover { background-color: ${light.ctaHover} !important; }`;
294
+
295
+ /**
296
+ * The dark override.
297
+ *
298
+ * A template rather than a generated string — it reads top to bottom as the CSS
299
+ * it is — but it interpolates `palette("dark")` rather than literals, so a
300
+ * colour still changes in exactly one place. What a template CAN do is forget a
301
+ * role; `__tests__/colors.test.ts` is what catches that.
302
+ *
303
+ * `!important` on every declaration is load-bearing, not defensive: the light
304
+ * colour is inline, inline beats a stylesheet on specificity, and without
305
+ * `!important` every rule here silently loses and dark quietly does nothing.
306
+ *
307
+ * **Read by Apple Mail, iOS Mail and Outlook for Mac, and by nobody else.** No
308
+ * Gmail client honours `prefers-color-scheme` on any surface, and Yahoo rewrites
309
+ * the query rather than honouring it — so this block has never reached them and a
310
+ * Gmail reader's dark mode is whatever their client does to our light colours on
311
+ * its own. On Gmail iOS that is a post-delivery inversion of the html part; on
312
+ * Gmail web it is nothing at all, and a light email stays light in a dark client.
313
+ * Closing THAT gap is inversion-indifferent authoring here — mid-tones, no pure
314
+ * #ffffff/#000000 in large fields, solid-fill CTAs — not another surface. See
315
+ * `company-semantics-backend/docs/dark-mode-probe.md` and ADR-CONTRACTS-091.
316
+ *
317
+ * `.cs-faint` states both `color` and `border-top-color` because one role lands
318
+ * on two properties — the `/* EOM *\/` span takes the first and ignores the
319
+ * second, each `<hr>` does the reverse. Two classes would be tidier to read and
320
+ * would mean the class no longer derives from the role, which is the property
321
+ * that makes a class carrying the wrong colour unrepresentable. A declaration an
322
+ * element ignores costs nothing; a class that can lie costs a debugging session.
323
+ *
324
+ * This block is additive and fail-safe. A client that strips `<style>` or ignores
325
+ * `prefers-color-scheme` keeps the inline light colours — which is exactly
326
+ * today's behaviour, not a degraded one.
327
+ *
328
+ * Custom properties would collapse all of this into one `:root` redefinition and
329
+ * are NOT used: caniemail puts them at ~45% support, and Outlook for Mac 16.80 —
330
+ * a current client, and one of the three that honours `prefers-color-scheme` at
331
+ * all — does not support them. In a client without them `color: var(--text, #1a1a1a)`
332
+ * is an invalid declaration and drops whole, so text loses its colour rather than
333
+ * falling back. They would break dark mode in a client we are building it for.
334
+ */
335
+ export const DARK_STYLE = `@media (prefers-color-scheme: dark) {
336
+ body { background-color: ${dark.background} !important; color: ${dark.text} !important; }
337
+ .cs-faint { color: ${dark.faint} !important; border-top-color: ${dark.faint} !important; }
338
+ .cs-meta { color: ${dark.meta} !important; }
339
+ .cs-cta { border-color: ${dark.cta} !important; }
340
+ .cs-cta-hover:hover { background-color: ${dark.ctaHover} !important; }
341
+ .cs-bubble { background: ${dark.bubble} !important; color: ${dark.onBubble} !important; }
342
+ .cs-dots { color: ${dark.dots} !important; }
343
+ .cs-link { color: ${dark.link} !important; }
344
+ .cs-destructive { color: ${dark.destructive} !important; }
345
+ }`;
@@ -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
@@ -33,23 +45,6 @@ export const MONO = `font-family: ${MONO_FONT_STACK};`;
33
45
  */
34
46
  export const FONT_SIZE = "13px";
35
47
 
36
- /**
37
- * The colour for a `warning` — the product's destructive token, resolved.
38
- *
39
- * This is `--destructive` from the app's `globals.css` `:root`
40
- * (`oklch(0.577 0.245 27.325)`) converted to sRGB hex. It is COPIED, not
41
- * imported, and it has to be: contracts sits ABOVE the app in the dependency
42
- * flow, so this package cannot read the app's stylesheet — and email cannot read
43
- * one either. There is no CSS to cascade from and no custom properties to
44
- * resolve, so every colour in this channel is a literal in an inline `style`.
45
- * If the token moves, this does not follow it; that divergence is the cost of
46
- * the arrow pointing one way.
47
- *
48
- * The `:root` value rather than `.dark`'s, because this channel is light-only by
49
- * construction — `./shells.ts` states `color: #1a1a1a` on the body.
50
- */
51
- export const DESTRUCTIVE = "#e7000b";
52
-
53
48
  /**
54
49
  * The gap AFTER a rendered line: `"normal"` is a blank line in plain text,
55
50
  * anything else is none.
@@ -62,3 +57,15 @@ export const SPACING: Record<Spacing, string> = {
62
57
  tight: "0 0 4px 0",
63
58
  none: "0",
64
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";