@company-semantics/contracts 35.1.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.
- package/package.json +7 -2
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +2580 -215
- package/src/notifications/content.ts +13 -1
- package/src/notifications/index.ts +15 -9
- package/src/notifications/renderers/email/README.md +122 -11
- package/src/notifications/renderers/email/__tests__/amp.test.ts +217 -0
- package/src/notifications/renderers/email/__tests__/colors.test.ts +227 -0
- package/src/notifications/renderers/email/__tests__/render.test.ts +86 -0
- package/src/notifications/renderers/email/__tests__/styles.test.ts +111 -0
- package/src/notifications/renderers/email/chat.ts +41 -30
- package/src/notifications/renderers/email/colors.ts +342 -0
- package/src/notifications/renderers/email/constants.ts +27 -20
- package/src/notifications/renderers/email/cta.ts +60 -10
- package/src/notifications/renderers/email/index.ts +19 -4
- package/src/notifications/renderers/email/render.ts +18 -15
- package/src/notifications/renderers/email/shells.ts +112 -9
- package/src/notifications/renderers/email/styles.ts +361 -0
- package/src/notifications/renderers/slack/README.md +66 -25
- package/src/notifications/renderers/slack/__tests__/index.test.ts +249 -41
- package/src/notifications/renderers/slack/index.ts +192 -107
|
@@ -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 —
|
|
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
|
-
/**
|
|
28
|
-
|
|
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";
|
|
@@ -7,19 +7,48 @@
|
|
|
7
7
|
* (`./chat`), which place it at different margins.
|
|
8
8
|
*
|
|
9
9
|
* INVARIANTS:
|
|
10
|
-
* - Pure
|
|
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 {
|
|
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:
|
|
67
|
+
margin: Spacing,
|
|
39
68
|
): { html: string; text: string } {
|
|
40
69
|
const { label, href } = cta;
|
|
41
70
|
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
91
|
+
? `<span ${styleClass("cta-label")}>${escapeHtml(label)}</span>`
|
|
48
92
|
: escapeHtml(label);
|
|
49
93
|
const chevrons = `>> ${labelHtml} <<`;
|
|
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}"
|
|
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"
|
|
55
|
-
<tr><td
|
|
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>`;
|
|
@@ -25,16 +25,30 @@ import type { Renderer } from "../../renderer";
|
|
|
25
25
|
|
|
26
26
|
import type { Spacing } from "./constants";
|
|
27
27
|
import { renderElement } from "./render";
|
|
28
|
-
import { type EmailLine, htmlShell, textShell } from "./shells";
|
|
28
|
+
import { ampShell, type EmailLine, htmlShell, textShell } from "./shells";
|
|
29
29
|
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* One rendered email — the email channel's natural output type.
|
|
32
|
+
*
|
|
33
|
+
* Three bodies, because an email IS a `multipart/alternative`: one message, whose
|
|
34
|
+
* parts a client chooses between. This is NOT three channels — a recipient gets one
|
|
35
|
+
* email — which is why AMP lives here rather than behind a second `Renderer`
|
|
36
|
+
* (ADR-CONTRACTS-089). Assembling the parts is delivery's job, and delivery is not
|
|
37
|
+
* contracts': today the backend sends `text` and `html` over SES's simple send and
|
|
38
|
+
* `amp` goes nowhere.
|
|
39
|
+
*/
|
|
31
40
|
export interface RenderedEmail {
|
|
32
41
|
/** Subject line — the notification's `metadata.title`. */
|
|
33
42
|
subject: string;
|
|
34
|
-
/** Plain-text body. */
|
|
43
|
+
/** Plain-text body (`text/plain`). */
|
|
35
44
|
text: string;
|
|
36
|
-
/** HTML body (every kind is
|
|
45
|
+
/** HTML body (`text/html`) — every kind is multi-output. */
|
|
37
46
|
html: string;
|
|
47
|
+
/**
|
|
48
|
+
* AMP4EMAIL body (`text/x-amp-html`) — read by Gmail, Yahoo and Mail.ru, and
|
|
49
|
+
* the only surface where the CTA's `:hover` reaches most recipients.
|
|
50
|
+
*/
|
|
51
|
+
amp: string;
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
/**
|
|
@@ -68,6 +82,7 @@ export const emailRenderer: Renderer<RenderedEmail> = {
|
|
|
68
82
|
subject: content.metadata.title,
|
|
69
83
|
text: textShell(lines),
|
|
70
84
|
html: htmlShell(lines),
|
|
85
|
+
amp: ampShell(lines),
|
|
71
86
|
};
|
|
72
87
|
},
|
|
73
88
|
};
|
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
|
158
|
+
`<span ${styleClass("warning", "destructive")}>${escapeHtml(text)}</span>`,
|
|
165
159
|
text,
|
|
166
160
|
trailing,
|
|
167
161
|
),
|
|
@@ -215,20 +209,29 @@ export function renderElement(
|
|
|
215
209
|
case "divider":
|
|
216
210
|
return [
|
|
217
211
|
{
|
|
218
|
-
html: `<hr
|
|
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
|
|
220
|
+
// size, every client uses them to reserve space before it loads, and
|
|
221
|
+
// `./styles.ts`'s `ampify` reads them to decide whether the AMP surface can
|
|
222
|
+
// draw an `<amp-img>` at all.
|
|
223
|
+
const dimensions =
|
|
224
|
+
element.width !== undefined && element.height !== undefined
|
|
225
|
+
? ` width="${element.width}" height="${element.height}"`
|
|
226
|
+
: "";
|
|
225
227
|
return [
|
|
226
228
|
{
|
|
227
|
-
html: `<img src="${element.src}" alt="${escapeHtml(element.alt)}"
|
|
229
|
+
html: `<img src="${escapeHtml(element.src)}" alt="${escapeHtml(element.alt)}"${dimensions} ${styleClass(`hero-${trailing}`)}>`,
|
|
228
230
|
text: element.alt,
|
|
229
231
|
spacing: trailing,
|
|
230
232
|
},
|
|
231
233
|
];
|
|
234
|
+
}
|
|
232
235
|
|
|
233
236
|
default: {
|
|
234
237
|
const _exhaustive: never = element;
|