@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.
- package/package.json +4 -1
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +615 -215
- package/src/notifications/content.ts +14 -1
- package/src/notifications/index.ts +15 -9
- package/src/notifications/renderers/email/README.md +120 -11
- 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 +42 -30
- package/src/notifications/renderers/email/colors.ts +345 -0
- package/src/notifications/renderers/email/constants.ts +27 -20
- package/src/notifications/renderers/email/cta.ts +58 -10
- package/src/notifications/renderers/email/index.ts +15 -3
- package/src/notifications/renderers/email/render.ts +16 -15
- package/src/notifications/renderers/email/shells.ts +49 -9
- package/src/notifications/renderers/email/styles.ts +319 -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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
const
|
|
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
|
|
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%"
|
|
126
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" ${styleClass(row)}>
|
|
115
127
|
<tr>
|
|
116
|
-
<td
|
|
117
|
-
<td
|
|
118
|
-
<tr><td
|
|
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
|
|
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
|
|
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, "
|
|
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
|
|
171
|
-
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%"
|
|
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
|
|
174
|
-
<td
|
|
175
|
-
<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%"
|
|
219
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" ${styleClass("chat-row-16")}>
|
|
208
220
|
<tr>
|
|
209
|
-
<td
|
|
210
|
-
<td
|
|
211
|
-
<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
|
|
266
|
+
const row: ChatRow =
|
|
255
267
|
next === "callToAction" || next === "continuation"
|
|
256
|
-
? "
|
|
257
|
-
: "
|
|
258
|
-
parts.push(renderBubble(item,
|
|
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 —
|
|
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";
|