@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.
- package/package.json +7 -2
- package/src/notifications/README.md +21 -19
- package/src/notifications/__tests__/README.md +42 -47
- package/src/notifications/__tests__/__snapshots__/README.md +15 -8
- package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +31 -0
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +2580 -215
- package/src/notifications/__tests__/context.test.ts +5 -5
- package/src/notifications/__tests__/definition.test.ts +2 -2
- package/src/notifications/__tests__/fixtures.ts +226 -0
- package/src/notifications/__tests__/kinds.test.ts +2 -3
- package/src/notifications/__tests__/registry.test.ts +27 -27
- package/src/notifications/__tests__/render-snapshot.test.ts +10 -6
- package/src/notifications/content.ts +13 -1
- package/src/notifications/index.ts +15 -9
- package/src/notifications/kinds/README.md +2 -2
- package/src/notifications/render.ts +6 -6
- package/src/notifications/renderers/email/README.md +132 -17
- package/src/notifications/renderers/email/__tests__/README.md +19 -16
- 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 +130 -36
- package/src/notifications/renderers/email/__tests__/styles.test.ts +111 -0
- package/src/notifications/renderers/email/chat.ts +44 -32
- package/src/notifications/renderers/email/colors.ts +342 -0
- package/src/notifications/renderers/email/constants.ts +27 -3
- package/src/notifications/renderers/email/cta.ts +60 -10
- package/src/notifications/renderers/email/index.ts +23 -8
- package/src/notifications/renderers/email/render.ts +31 -15
- package/src/notifications/renderers/email/shells.ts +115 -11
- 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
- package/src/notifications/text.ts +2 -2
- package/src/notifications/__tests__/output-parity.golden.ts +0 -363
- 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
|
|
12
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
const
|
|
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
|
|
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%"
|
|
125
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" ${styleClass(row)}>
|
|
114
126
|
<tr>
|
|
115
|
-
<td
|
|
116
|
-
<td
|
|
117
|
-
<tr><td
|
|
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
|
|
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
|
|
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, "
|
|
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
|
|
170
|
-
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%"
|
|
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
|
|
173
|
-
<td
|
|
174
|
-
<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%"
|
|
218
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" ${styleClass("chat-row-16")}>
|
|
207
219
|
<tr>
|
|
208
|
-
<td
|
|
209
|
-
<td
|
|
210
|
-
<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
|
|
265
|
+
const row: ChatRow =
|
|
254
266
|
next === "callToAction" || next === "continuation"
|
|
255
|
-
? "
|
|
256
|
-
: "
|
|
257
|
-
parts.push(renderBubble(item,
|
|
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 —
|
|
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
|
|
@@ -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
|
|
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>`;
|