@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
|
@@ -138,11 +138,23 @@ export interface Signature {
|
|
|
138
138
|
signer?: string;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* A leading image. `alt` is required — a text-only channel has nothing else.
|
|
143
|
+
*
|
|
144
|
+
* `width`/`height` are the image's intrinsic pixel dimensions. They are optional
|
|
145
|
+
* because a channel that can measure an image itself does not need to be told, and
|
|
146
|
+
* requiring them would put a rendering constraint into channel-agnostic content.
|
|
147
|
+
* But a channel MAY be unable to draw one without them: AMP's `<amp-img>` requires
|
|
148
|
+
* explicit dimensions to reserve layout before the image loads, so the email
|
|
149
|
+
* channel's AMP surface degrades to the `alt` text when they are absent
|
|
150
|
+
* (ADR-CONTRACTS-089). Supplying them is what makes the image drawable everywhere.
|
|
151
|
+
*/
|
|
142
152
|
export interface HeroImage {
|
|
143
153
|
type: "heroImage";
|
|
144
154
|
src: string;
|
|
145
155
|
alt: string;
|
|
156
|
+
width?: number;
|
|
157
|
+
height?: number;
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
// =============================================================================
|
|
@@ -58,17 +58,23 @@ export type { RenderedEmail } from "./renderers/email";
|
|
|
58
58
|
|
|
59
59
|
export { emailRenderer } from "./renderers/email";
|
|
60
60
|
|
|
61
|
+
// The `Slack*` block types this barrel used to export were a hand-written subset
|
|
62
|
+
// of Block Kit and are GONE (ADR-CONTRACTS-090). Slack's own `@slack/types` is
|
|
63
|
+
// the vocabulary now, re-exported here so a consumer need not depend on it
|
|
64
|
+
// directly: `SlackBlock` -> `KnownBlock`, `SlackSectionBlock` -> `SectionBlock`,
|
|
65
|
+
// `SlackMrkdwnText` -> `MrkdwnElement`, `SlackPlainText` -> `PlainTextElement`.
|
|
61
66
|
export type {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
ActionsBlock,
|
|
68
|
+
ContextBlock,
|
|
69
|
+
DividerBlock,
|
|
70
|
+
HeaderBlock,
|
|
71
|
+
ImageBlock,
|
|
72
|
+
KnownBlock,
|
|
73
|
+
MrkdwnElement,
|
|
74
|
+
PlainTextElement,
|
|
75
|
+
RichTextBlock,
|
|
76
|
+
SectionBlock,
|
|
68
77
|
SlackMessage,
|
|
69
|
-
SlackMrkdwnText,
|
|
70
|
-
SlackPlainText,
|
|
71
|
-
SlackSectionBlock,
|
|
72
78
|
} from "./renderers/slack";
|
|
73
79
|
|
|
74
80
|
export { slackRenderer } from "./renderers/slack";
|
|
@@ -10,21 +10,76 @@ deleted, what replaced it. That layer offered templates a component per intent
|
|
|
10
10
|
here the CONTENT names the intent and this directory is the only thing that knows
|
|
11
11
|
the markup. The strings are the same strings.
|
|
12
12
|
|
|
13
|
-
| module | what it holds
|
|
14
|
-
| ---------------- |
|
|
15
|
-
| `index.ts` | `emailRenderer` + `RenderedEmail`, the channel's natural type
|
|
16
|
-
| `render.ts` | each `NotificationElement` → its `<p>`/table markup, and spacing
|
|
17
|
-
| `chat.ts` | `chatUnit` → HTML bubbles and plain-text box art
|
|
18
|
-
| `cta.ts` | the `>> LABEL <<` button, shared by a standalone CTA and a chat one
|
|
19
|
-
| `shells.ts` | `htmlShell` / `textShell` — what makes one email
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
13
|
+
| module | what it holds |
|
|
14
|
+
| ---------------- | -------------------------------------------------------------------------------------- |
|
|
15
|
+
| `index.ts` | `emailRenderer` + `RenderedEmail`, the channel's natural type |
|
|
16
|
+
| `render.ts` | each `NotificationElement` → its class-only `<p>`/table markup, and spacing |
|
|
17
|
+
| `chat.ts` | `chatUnit` → HTML bubbles and plain-text box art |
|
|
18
|
+
| `cta.ts` | the `>> LABEL <<` button, shared by a standalone CTA and a chat one |
|
|
19
|
+
| `shells.ts` | `htmlShell` / `ampShell` / `textShell` — what makes one email three surfaces |
|
|
20
|
+
| `styles.ts` | every declaration, as recipes — plus `inlineStyles` and `ampify` |
|
|
21
|
+
| `colors.ts` | every colour, by role, in every scheme — and the dark stylesheet |
|
|
22
|
+
| `constants.ts` | typography and layout (`MONO_DECL`, `FONT_SIZE`, `SPACING`, `CTA_PAD`) + `COMPANY_URL` |
|
|
23
|
+
| `escape-html.ts` | `escapeHtml` |
|
|
22
24
|
|
|
23
25
|
`constants.ts` and `escape-html.ts` moved here from the old `src/email/render`
|
|
24
26
|
before it was deleted. The brand NAME is deliberately not among them: it is
|
|
25
27
|
`../../context`'s `COMPANY_NAME`, because it is the same answer on every channel
|
|
26
28
|
and renderers read it from `context.brand`.
|
|
27
29
|
|
|
30
|
+
## Three surfaces, one markup (ADR-CONTRACTS-089)
|
|
31
|
+
|
|
32
|
+
An email is a `multipart/alternative`: `text/plain`, `text/html` and
|
|
33
|
+
`text/x-amp-html` are three presentations of ONE message, and `RenderedEmail`
|
|
34
|
+
carries all three. AMP is not a fourth channel — a recipient gets one email.
|
|
35
|
+
|
|
36
|
+
The catch is that the two HTML-ish surfaces disagree about styling at the root.
|
|
37
|
+
AMP4EMAIL forbids inline `style` attributes and `!important`; the html surface is
|
|
38
|
+
built from both, deliberately (see Colour below). So **the markup belongs to
|
|
39
|
+
neither**: `render.ts`, `cta.ts` and `chat.ts` emit CLASS-ONLY markup, and each
|
|
40
|
+
shell spends `styles.ts` its own way —
|
|
41
|
+
|
|
42
|
+
- `htmlShell` runs `inlineStyles`, which turns recipe classes back into the exact
|
|
43
|
+
inline styles this channel has always shipped;
|
|
44
|
+
- `ampShell` runs `ampify` (the `<img>` → `<amp-img>` swap) and states the same
|
|
45
|
+
recipes as `<style amp-custom>`.
|
|
46
|
+
|
|
47
|
+
`EmailLine` gains no third field: `../../renderer.ts` warns against exactly that,
|
|
48
|
+
and a surface is a way of SPENDING the lines rather than a thing a line carries.
|
|
49
|
+
The cost is that `EmailLine.html` is now an intermediate representation —
|
|
50
|
+
`render.ts` no longer reads as the bytes it emits, and the snapshot is where the
|
|
51
|
+
real markup is legible.
|
|
52
|
+
|
|
53
|
+
**What AMP is for:** `:hover`. ADR-CONTRACTS-088 bought a hover state that Gmail
|
|
54
|
+
does not honour, and Gmail is most opens. AMP is where that rule reaches people.
|
|
55
|
+
|
|
56
|
+
**What AMP does not have:** dark. AMP4EMAIL disallows the `prefers-color-scheme`
|
|
57
|
+
media feature outright. It costs nothing — see Colour.
|
|
58
|
+
|
|
59
|
+
`pnpm validate:amp` runs the real AMP validator. `__tests__/amp.test.ts` asserts
|
|
60
|
+
the constraints we KNOW on every run, which is not the same claim: it passed once
|
|
61
|
+
while every fixture was invalid.
|
|
62
|
+
|
|
63
|
+
## Colour — the thing this channel has and the others do not
|
|
64
|
+
|
|
65
|
+
`colors.ts` is the only place a colour lives, and it is the channel's own rather
|
|
66
|
+
than the layer's. Email hand-authors colour because it is the only channel with
|
|
67
|
+
no client-side semantic vocabulary to lean on: `../slack` says `:warning:` and
|
|
68
|
+
`context` and lets Slack's client theme them (Block Kit exposes no author-set
|
|
69
|
+
colour at all), and `../sms` has none to have. A shared `notifications/colors.ts`
|
|
70
|
+
would look DRY and would put a channel's presentation back inside the
|
|
71
|
+
channel-agnostic layer — the coupling `../../renderer.ts` exists to prevent. A
|
|
72
|
+
channel that needs colour gets its own file next to its own renderer.
|
|
73
|
+
|
|
74
|
+
Colour is named by ROLE, and a scheme owes an answer to every role. Roles are
|
|
75
|
+
meaning, not coincidence: `border`, `bubble`, `meta` and `dots` all answer `#666`
|
|
76
|
+
in light and are still four roles, because a CTA's border and a chat bubble's
|
|
77
|
+
fill have no reason to move together.
|
|
78
|
+
|
|
79
|
+
The channel renders in **one or more schemes** — two today (`light`, `dark`), and
|
|
80
|
+
callers ask `palette(scheme)` rather than importing a concrete palette, so a
|
|
81
|
+
third is an entry in a table rather than an edit to every call site.
|
|
82
|
+
|
|
28
83
|
## Spacing — the thing the content model does not carry
|
|
29
84
|
|
|
30
85
|
The old layer passed a `Spacing` per block; `../../kinds` deliberately dropped
|
|
@@ -71,8 +126,64 @@ expect to style it properly rather than trust the placeholder.
|
|
|
71
126
|
- Values arrive pre-formatted: dates, title-casing and truncation are `compose`'s
|
|
72
127
|
work. Nothing here re-formats a value.
|
|
73
128
|
- Every user-controlled field in the HTML surface passes through `escapeHtml`
|
|
74
|
-
before interpolation
|
|
75
|
-
|
|
129
|
+
before interpolation — INCLUDING `href` and `src`, which reach the markup as
|
|
130
|
+
URLs rather than as text and were interpolated raw until ADR-CONTRACTS-089. A
|
|
131
|
+
URL that can close its attribute can forge another; under `inlineStyles` it
|
|
132
|
+
could also forge a `csr-` class and write any declaration in the registry into
|
|
133
|
+
someone else's element. That is also what makes the regex in `inlineStyles` safe:
|
|
134
|
+
nothing that reaches it can contain an unescaped `"`.
|
|
135
|
+
- The markup is CLASS-ONLY. `cs-` role classes SHIP (the dark stylesheet needs the
|
|
136
|
+
hook); `csr-` recipe classes DO NOT — `inlineStyles` spends and drops them. That
|
|
137
|
+
prefix split is a mechanism, not a convention: it is what lets one markup serve
|
|
138
|
+
two surfaces, and it is why `__tests__/colors.test.ts` needs no filter — it scans
|
|
139
|
+
rendered output, where recipe classes no longer exist.
|
|
140
|
+
- `MONO_DECL` / `FONT_SIZE` / `SPACING` / `CTA_PAD` are this channel's own and are
|
|
141
|
+
NOT exported
|
|
76
142
|
from the domain barrel: a margin is not vocabulary. They were duplicated from
|
|
77
143
|
the old `email/render/blocks`'s private copies while both layers existed; that
|
|
78
144
|
duplication ended with the directory, and these are now the only copies.
|
|
145
|
+
`colors.ts` is unexported for the same reason — a colour is not vocabulary
|
|
146
|
+
either.
|
|
147
|
+
- NO module here states a colour. Every colour comes from `colors.ts` via a role;
|
|
148
|
+
`__tests__/colors.test.ts` fails on a raw hex anywhere else in the directory,
|
|
149
|
+
including one in a comment, because a comment naming a literal goes stale the
|
|
150
|
+
first time the palette is tuned.
|
|
151
|
+
- On the HTML surface, light is inline and dark is the `<style>` block, and that is
|
|
152
|
+
the mechanism rather than duplication: one source each for two schemes, both from
|
|
153
|
+
`colors.ts`. Every dark declaration carries `!important` because the inline light
|
|
154
|
+
colour would otherwise win on specificity and dark would silently do nothing. On
|
|
155
|
+
the AMP surface there is no inline style, so the recipes carry light and source
|
|
156
|
+
order alone would resolve a cascade — which is moot, because AMP has no dark.
|
|
157
|
+
- `:hover` is the one exception, because it has no inline form: BOTH schemes state
|
|
158
|
+
it in the stylesheet (`BASE_STYLE` light, `DARK_STYLE` dark, resolved by source
|
|
159
|
+
order). On the HTML surface it is opportunistic — Apple Mail honours it, Gmail and
|
|
160
|
+
classic Outlook do not — and needs no fallback, because without it the button
|
|
161
|
+
simply rests. It is also the ONE rule the AMP surface shares, via
|
|
162
|
+
`hoverStyle(false)`; AMP forbids `!important`, which is why that template takes a
|
|
163
|
+
parameter and `DARK_STYLE` does not.
|
|
164
|
+
- The AMP surface has NO dark scheme, and loses nothing by it. AMP4EMAIL disallows
|
|
165
|
+
the `prefers-color-scheme` media feature (checked against the AMP validator, with
|
|
166
|
+
and without `data-css-strict`), so it cannot. The audiences are disjoint: dark
|
|
167
|
+
reaches Apple Mail, iOS Mail and Outlook for Mac; AMP reaches Gmail, Yahoo and
|
|
168
|
+
Mail.ru — the force-inverting clients that never received our dark colours anyway.
|
|
169
|
+
AMP swaps a scheme its readers never got for a hover state they never got.
|
|
170
|
+
- Role hooks ride the AMP surface unstyled (`cs-faint`, `cs-bubble`, `cs-cta`,
|
|
171
|
+
`cs-link`), because their only rules live in `DARK_STYLE`. Their colours arrive
|
|
172
|
+
via each recipe's `roleDecl`, so this is dead bytes rather than a bug — the price
|
|
173
|
+
of one markup. `.cs-cta-hover` is the exception: it is the feature.
|
|
174
|
+
- A hover hook goes on a CTA ONLY when it has an `href`. An unlinked label is a
|
|
175
|
+
payload (an OTP code), and a hover state on it would promise a click that does
|
|
176
|
+
not exist — the same invariant that keeps it a `<span>` rather than an `<a>`.
|
|
177
|
+
- A linked CTA's whole box is the click target: the padding sits on the `<a>`
|
|
178
|
+
(`display: block`), not the cell. Outlook ignores `display`, so an
|
|
179
|
+
`<!--[if mso]>` block restores the cell padding there and the box degrades to
|
|
180
|
+
today's text click. The padding is one constant shared by the anchor, the cell,
|
|
181
|
+
and the MSO block — they must agree.
|
|
182
|
+
- The `color-scheme` metas and `DARK_STYLE` ship together. The declaration is a
|
|
183
|
+
promise: made alone it stops Apple Mail protecting our colours while leaving
|
|
184
|
+
light text on a dark background — worse than declaring light-only. This channel
|
|
185
|
+
WAS light-only by construction until ADR-CONTRACTS-088; it is not any more.
|
|
186
|
+
- Dark reaches Apple Mail, iOS Mail and Outlook for Mac. Gmail and Outlook
|
|
187
|
+
force-invert and are unreachable — the dark path is additive, and a client that
|
|
188
|
+
strips `<style>` keeps the inline light colours, which is the status quo rather
|
|
189
|
+
than a degradation.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AMP surface's constraints (ADR-CONTRACTS-089).
|
|
3
|
+
*
|
|
4
|
+
* **What this file can and cannot claim.** AMP validity is defined by the AMP
|
|
5
|
+
* validator, and the validator fetches itself over the network — so it cannot live
|
|
6
|
+
* in this package's pure suite and runs behind `pnpm validate:amp` instead. What is
|
|
7
|
+
* asserted here is narrower and honest: *the constraints we know*, checked over
|
|
8
|
+
* every fixture. A green run here means "we did not do any of the things we know
|
|
9
|
+
* AMP forbids", not "this is valid AMP".
|
|
10
|
+
*
|
|
11
|
+
* The constraints are structural rather than cosmetic. Each one is a thing the html
|
|
12
|
+
* surface does deliberately and this surface must not do at all — which is why the
|
|
13
|
+
* markup belongs to neither and both are derived from `../styles.ts`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, it } from "vitest";
|
|
17
|
+
|
|
18
|
+
import { hoverStyle } from "../colors";
|
|
19
|
+
import { NOTIFICATION_FIXTURES, fixtureKey } from "../../../__tests__/fixtures";
|
|
20
|
+
import type { RenderContext } from "../../../context";
|
|
21
|
+
import { NOTIFICATION_DEFINITIONS } from "../../../registry";
|
|
22
|
+
import { emailRenderer } from "../index";
|
|
23
|
+
import { ampify } from "../styles";
|
|
24
|
+
|
|
25
|
+
const CONTEXT: RenderContext = {
|
|
26
|
+
brand: { name: "Company Semantics", copyrightYear: 2026 },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Gmail's `amp-custom` ceiling. */
|
|
30
|
+
const AMP_CUSTOM_LIMIT = 75_000;
|
|
31
|
+
|
|
32
|
+
/** Every fixture, rendered once. */
|
|
33
|
+
const RENDERED = NOTIFICATION_FIXTURES.map((fixture) => {
|
|
34
|
+
const definition = NOTIFICATION_DEFINITIONS[fixture.kind];
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
|
+
const content = definition.compose(fixture.payload as any, CONTEXT);
|
|
37
|
+
return {
|
|
38
|
+
key: fixtureKey(fixture.kind, fixture.name),
|
|
39
|
+
...emailRenderer.render(content, CONTEXT),
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** The `<style amp-custom>` block's contents. */
|
|
44
|
+
function ampCustom(amp: string): string {
|
|
45
|
+
const open = "<style amp-custom>";
|
|
46
|
+
const start = amp.indexOf(open);
|
|
47
|
+
return amp.slice(start + open.length, amp.indexOf("</style></head>"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Everything a reader would actually read, tags removed. */
|
|
51
|
+
function visibleText(html: string): string {
|
|
52
|
+
return html
|
|
53
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/g, "")
|
|
54
|
+
.replace(/<script[\s\S]*?<\/script>/g, "")
|
|
55
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
56
|
+
.replace(/<[^>]+>/g, " ")
|
|
57
|
+
.replace(/\s+/g, " ")
|
|
58
|
+
.trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("the AMP surface", () => {
|
|
62
|
+
it("emits every fixture", () => {
|
|
63
|
+
// Guards every loop below: a fixture list that silently emptied would turn
|
|
64
|
+
// each of them into a vacuous pass.
|
|
65
|
+
expect(RENDERED.length).toBeGreaterThan(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe.each(RENDERED)("$key", ({ amp }) => {
|
|
69
|
+
it("declares itself AMP, with the runtime and the boilerplate", () => {
|
|
70
|
+
expect(amp.startsWith("<!DOCTYPE html>\n<html amp4email")).toBe(true);
|
|
71
|
+
expect(amp).toContain(
|
|
72
|
+
'<script async src="https://cdn.ampproject.org/v0.js"></script>',
|
|
73
|
+
);
|
|
74
|
+
// The boilerplate hides the body until the runtime unhides it — which is
|
|
75
|
+
// why an AMP document that cannot run scripts renders blank rather than
|
|
76
|
+
// unstyled.
|
|
77
|
+
expect(amp).toContain(
|
|
78
|
+
"<style amp4email-boilerplate>body{visibility:hidden}</style>",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("carries no inline style — the constraint the whole split exists for", () => {
|
|
83
|
+
expect(amp).not.toContain('style="');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("carries no !important, which amp-custom forbids", () => {
|
|
87
|
+
expect(ampCustom(amp)).not.toContain("!important");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("carries no conditional comment", () => {
|
|
91
|
+
// `MSO_CTA_STYLE` is `htmlShell`'s business. It lives in that shell's
|
|
92
|
+
// `<head>`, never in an `EmailLine`, so this holds by construction rather
|
|
93
|
+
// than by stripping.
|
|
94
|
+
expect(amp).not.toContain("<!--[if");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("has exactly one amp-custom stylesheet, in the head", () => {
|
|
98
|
+
expect(amp.match(/<style amp-custom>/g)).toHaveLength(1);
|
|
99
|
+
expect(amp.indexOf("<style amp-custom>")).toBeLessThan(
|
|
100
|
+
amp.indexOf("</head>"),
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("fits Gmail's amp-custom budget", () => {
|
|
105
|
+
expect(ampCustom(amp).length).toBeLessThan(AMP_CUSTOM_LIMIT);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("draws images as amp-img, never img", () => {
|
|
109
|
+
expect(amp).not.toMatch(/<img\s/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("styles every recipe class it applies", () => {
|
|
113
|
+
// The end-to-end direction: start from the output, so a recipe class that
|
|
114
|
+
// reaches the markup with no rule behind it is caught. On this surface a
|
|
115
|
+
// recipe is the ONLY thing carrying an element's appearance — `htmlShell`
|
|
116
|
+
// spends them inline and drops them; here they must all land as rules, or
|
|
117
|
+
// the element renders naked.
|
|
118
|
+
//
|
|
119
|
+
// Role hooks (`cs-`) are deliberately NOT required to have one. They ride
|
|
120
|
+
// along because the markup is shared with the html surface, where
|
|
121
|
+
// `DARK_STYLE` needs them — and dark is exactly what this surface cannot
|
|
122
|
+
// have. So `cs-faint`/`cs-bubble`/`cs-cta`/`cs-link` are inert here: their
|
|
123
|
+
// colours already arrived via their recipe's `roleDecl`. That is the price
|
|
124
|
+
// of one markup, and it is bytes rather than correctness. The one role rule
|
|
125
|
+
// this surface DOES carry is asserted separately below — it is the feature.
|
|
126
|
+
const body = amp.slice(amp.indexOf("<body"));
|
|
127
|
+
const applied = new Set(
|
|
128
|
+
[...body.matchAll(/class="([^"]+)"/g)].flatMap((m) =>
|
|
129
|
+
m[1].split(/\s+/),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
const recipes = [...applied].filter((c) => c.startsWith("csr-"));
|
|
133
|
+
expect(recipes.length).toBeGreaterThan(0);
|
|
134
|
+
const sheet = ampCustom(amp);
|
|
135
|
+
for (const className of recipes) {
|
|
136
|
+
expect(sheet, `${className} is applied but never styled`).toMatch(
|
|
137
|
+
new RegExp(`\\.${className} \\{`),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("says exactly what the html surface says", () => {
|
|
144
|
+
// The claim `EmailLine` exists to make, now across three surfaces instead of
|
|
145
|
+
// two: the parts of a `multipart/alternative` are ALTERNATIVES, so a client
|
|
146
|
+
// choosing between them must not change what the notification said. This is
|
|
147
|
+
// what a second hand-authored AMP template could never promise.
|
|
148
|
+
for (const { key, html, amp } of RENDERED) {
|
|
149
|
+
expect(visibleText(amp), `${key} says something else in AMP`).toBe(
|
|
150
|
+
visibleText(html),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("states the hover rule without !important, and the html surface with", () => {
|
|
156
|
+
// The one rule both surfaces carry, and the only thing they disagree on: the
|
|
157
|
+
// html surface must beat an inline style, this one has none to beat and
|
|
158
|
+
// forbids the keyword. One template, one parameter; see `../colors.ts`.
|
|
159
|
+
expect(hoverStyle(true)).toContain("!important");
|
|
160
|
+
expect(hoverStyle(false)).not.toContain("!important");
|
|
161
|
+
// The same rule, not merely two rules that both exist.
|
|
162
|
+
expect(hoverStyle(true).replaceAll(" !important", "")).toBe(
|
|
163
|
+
hoverStyle(false),
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("carries the hover rule — the reason this surface exists at all", () => {
|
|
168
|
+
// ADR-CONTRACTS-088 bought a `:hover` state that Gmail does not honour, and
|
|
169
|
+
// Gmail is most opens. This surface is where that rule finally reaches a
|
|
170
|
+
// recipient, so its presence is the feature, not an implementation detail.
|
|
171
|
+
const { amp } = RENDERED[0];
|
|
172
|
+
expect(ampCustom(amp)).toContain(".cs-cta-hover:hover");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("carries no dark scheme, because AMP disallows the media feature", () => {
|
|
176
|
+
// NOT an omission to fix later: `prefers-color-scheme` is a disallowed media
|
|
177
|
+
// feature in amp-custom (verified against the AMP validator, with and without
|
|
178
|
+
// `data-css-strict`), so this surface cannot have dark in any form. It costs
|
|
179
|
+
// nothing — the dark block's clients (Apple Mail, iOS Mail, Outlook for Mac)
|
|
180
|
+
// and this surface's clients (Gmail, Yahoo, Mail.ru) are disjoint sets, and
|
|
181
|
+
// the latter force-invert rather than read a stylesheet.
|
|
182
|
+
//
|
|
183
|
+
// This test exists so that "add dark mode to AMP" fails here with the reason,
|
|
184
|
+
// rather than in `pnpm validate:amp` with a CSS syntax error.
|
|
185
|
+
for (const { key, amp } of RENDERED) {
|
|
186
|
+
expect(amp, `${key} would be invalid AMP`).not.toContain(
|
|
187
|
+
"prefers-color-scheme",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("ampify", () => {
|
|
193
|
+
it("gives a sized image explicit layout", () => {
|
|
194
|
+
expect(
|
|
195
|
+
ampify(
|
|
196
|
+
'<img src="a.png" alt="A" width="600" height="200" class="csr-hero-normal">',
|
|
197
|
+
),
|
|
198
|
+
).toBe(
|
|
199
|
+
'<amp-img src="a.png" alt="A" width="600" height="200" class="csr-hero-normal" layout="intrinsic"></amp-img>',
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("degrades an unsized image to its alt text, keeping its spacing", () => {
|
|
204
|
+
// `../../content.ts` makes dimensions optional, so this surface must have an
|
|
205
|
+
// answer for their absence. `alt` is required exactly so that a channel with
|
|
206
|
+
// nothing else has something to say.
|
|
207
|
+
expect(
|
|
208
|
+
ampify('<img src="a.png" alt="A logo" class="csr-hero-tight">'),
|
|
209
|
+
).toBe('<p class="csr-p-tight">A logo</p>');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("leaves markup with no image alone", () => {
|
|
213
|
+
const plain = '<p class="csr-p-none">x</p>';
|
|
214
|
+
expect(ampify(plain)).toBe(plain);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that keep `../colors.ts` the only place colour lives
|
|
3
|
+
* (ADR-CONTRACTS-088).
|
|
4
|
+
*
|
|
5
|
+
* The markup is not asserted here — `../../../__tests__/render-snapshot.test.ts`
|
|
6
|
+
* locks that. These are the claims a snapshot cannot make, because a snapshot
|
|
7
|
+
* records what the output IS and every one of these is about what it must never
|
|
8
|
+
* become:
|
|
9
|
+
*
|
|
10
|
+
* - A dark stylesheet is invisible to the snapshot's reviewer in the sense that
|
|
11
|
+
* matters: you can read the `<style>` block in a diff and still not notice that
|
|
12
|
+
* a role is missing from it, because the light bytes look perfect either way.
|
|
13
|
+
* Dark mode fails silently. These tests are the alarm.
|
|
14
|
+
* - `DARK_STYLE` is a hand-written template, deliberately (it reads as the CSS it
|
|
15
|
+
* is). The cost of a template over generated output is that it can forget a
|
|
16
|
+
* role; this is where that cost is paid back.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
import { describe, expect, it } from "vitest";
|
|
24
|
+
|
|
25
|
+
import type {
|
|
26
|
+
NotificationContent,
|
|
27
|
+
NotificationElement,
|
|
28
|
+
} from "../../../content";
|
|
29
|
+
import type { RenderContext } from "../../../context";
|
|
30
|
+
import {
|
|
31
|
+
BASE_STYLE,
|
|
32
|
+
CLASS_ROLES,
|
|
33
|
+
DARK_STYLE,
|
|
34
|
+
HOVER_ROLES,
|
|
35
|
+
palette,
|
|
36
|
+
ROLES,
|
|
37
|
+
roleClass,
|
|
38
|
+
} from "../colors";
|
|
39
|
+
import { emailRenderer } from "../index";
|
|
40
|
+
|
|
41
|
+
const CONTEXT: RenderContext = {
|
|
42
|
+
brand: { name: "Company Semantics", copyrightYear: 2026 },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The class name inside a role's attribute — `cs-meta` from `class="cs-meta"`. */
|
|
46
|
+
function classNameOf(role: (typeof ROLES)[number]): string {
|
|
47
|
+
const match = roleClass(role).match(/class="([^"]+)"/);
|
|
48
|
+
if (!match) throw new Error(`roleClass(${role}) is not a class attribute`);
|
|
49
|
+
return match[1];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One notification exercising every colour-bearing element. The chat unit carries
|
|
54
|
+
* both continuation shapes on purpose: dots followed by a CTA fold into it, and
|
|
55
|
+
* dots followed by nothing render standalone — two different emitters, two
|
|
56
|
+
* chances to forget a class.
|
|
57
|
+
*/
|
|
58
|
+
const EVERY_COLOURED_ELEMENT: NotificationContent = {
|
|
59
|
+
metadata: { kind: "chat.shared", title: "Colour coverage" },
|
|
60
|
+
sections: [
|
|
61
|
+
{
|
|
62
|
+
elements: [
|
|
63
|
+
{ type: "warning" },
|
|
64
|
+
{ type: "divider" },
|
|
65
|
+
{ type: "callToAction", label: "Open", href: "https://example.com/a" },
|
|
66
|
+
{
|
|
67
|
+
type: "chatUnit",
|
|
68
|
+
items: [
|
|
69
|
+
{ type: "message", role: "user", text: "Hello", from: "Sam Chen" },
|
|
70
|
+
{ type: "continuation" },
|
|
71
|
+
{
|
|
72
|
+
type: "callToAction",
|
|
73
|
+
label: "Reply",
|
|
74
|
+
href: "https://example.com/b",
|
|
75
|
+
},
|
|
76
|
+
{ type: "message", role: "assistant", text: "Hi back" },
|
|
77
|
+
{ type: "continuation" },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
{ type: "signature" },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
describe("email colours", () => {
|
|
87
|
+
it("gives every role a dark answer that reaches the stylesheet", () => {
|
|
88
|
+
const dark = palette("dark");
|
|
89
|
+
for (const role of ROLES) {
|
|
90
|
+
expect(DARK_STYLE, `role "${role}" is absent from DARK_STYLE`).toContain(
|
|
91
|
+
dark[role],
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("writes a rule for every class-bearing role", () => {
|
|
97
|
+
for (const role of CLASS_ROLES) {
|
|
98
|
+
// `:hover` roles are selected as `.cs-x:hover {`, resting ones as `.cs-x {`.
|
|
99
|
+
const selector = new RegExp(`\\.${classNameOf(role)}(:[a-z-]+)? \\{`);
|
|
100
|
+
expect(DARK_STYLE, `role "${role}" has a class but no dark rule`).toMatch(
|
|
101
|
+
selector,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("states every hover role in BOTH schemes, since :hover cannot be inline", () => {
|
|
107
|
+
// Every other role is inline-light + dark override. A hover role has no inline
|
|
108
|
+
// form, so a light answer that never reaches BASE_STYLE is simply lost — and
|
|
109
|
+
// nothing else in this file would notice.
|
|
110
|
+
for (const role of HOVER_ROLES) {
|
|
111
|
+
const selector = new RegExp(`\\.${classNameOf(role)}:hover \\{`);
|
|
112
|
+
expect(BASE_STYLE, `hover role "${role}" has no light rule`).toMatch(
|
|
113
|
+
selector,
|
|
114
|
+
);
|
|
115
|
+
expect(BASE_STYLE, `hover role "${role}" light value`).toContain(
|
|
116
|
+
palette("light")[role],
|
|
117
|
+
);
|
|
118
|
+
expect(DARK_STYLE, `hover role "${role}" has no dark rule`).toMatch(
|
|
119
|
+
selector,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("marks every declaration !important, in both blocks", () => {
|
|
125
|
+
// Load-bearing, not defensive: the light colour is inline, inline beats a
|
|
126
|
+
// stylesheet on specificity, and a rule that loses that fight does nothing
|
|
127
|
+
// at all while looking entirely correct.
|
|
128
|
+
const rules = `${BASE_STYLE}\n${DARK_STYLE}`.split("\n").filter((line) => {
|
|
129
|
+
const start = line.trimStart();
|
|
130
|
+
return start.startsWith(".") || start.startsWith("body");
|
|
131
|
+
});
|
|
132
|
+
// Guards the filter above: a reformat that matches no lines would turn the
|
|
133
|
+
// loop below into a vacuous pass. `body` is the +1; BASE_STYLE contributes
|
|
134
|
+
// one line per hover role.
|
|
135
|
+
expect(rules).toHaveLength(CLASS_ROLES.length + 1 + HOVER_ROLES.length);
|
|
136
|
+
|
|
137
|
+
for (const rule of rules) {
|
|
138
|
+
const body = rule.slice(rule.indexOf("{") + 1, rule.lastIndexOf("}"));
|
|
139
|
+
const declarations = body
|
|
140
|
+
.split(";")
|
|
141
|
+
.map((d) => d.trim())
|
|
142
|
+
.filter(Boolean);
|
|
143
|
+
expect(declarations.length).toBeGreaterThan(0);
|
|
144
|
+
for (const declaration of declarations) {
|
|
145
|
+
expect(
|
|
146
|
+
declaration,
|
|
147
|
+
`"${declaration}" would lose to the inline style`,
|
|
148
|
+
).toContain("!important");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("styles every class the renderer actually emits", () => {
|
|
154
|
+
// The end-to-end direction: the tests above start from the palette, this one
|
|
155
|
+
// starts from the output, so a class hook added to a tag without a matching
|
|
156
|
+
// rule is caught from the side the palette cannot see.
|
|
157
|
+
const { html } = emailRenderer.render(EVERY_COLOURED_ELEMENT, CONTEXT);
|
|
158
|
+
// One attribute can carry several classes (`class="cs-cta cs-cta-hover"`).
|
|
159
|
+
const emitted = new Set(
|
|
160
|
+
[...html.matchAll(/class="([^"]+)"/g)].flatMap((match) =>
|
|
161
|
+
match[1].split(/\s+/),
|
|
162
|
+
),
|
|
163
|
+
);
|
|
164
|
+
expect(emitted.size).toBeGreaterThan(0);
|
|
165
|
+
const stylesheet = `${BASE_STYLE}\n${DARK_STYLE}`;
|
|
166
|
+
for (const className of emitted) {
|
|
167
|
+
expect(stylesheet, `${className} is emitted but never styled`).toMatch(
|
|
168
|
+
new RegExp(`\\.${className}(:[a-z-]+)? \\{`),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("puts the hover hook on a CTA that links, and never on one that does not", () => {
|
|
174
|
+
// The invariant `./cta.ts` already carried — an unlinked label is a payload,
|
|
175
|
+
// not a button — extended to hover. Lighting up an OTP code under the pointer
|
|
176
|
+
// promises a click that does not exist.
|
|
177
|
+
const hoverClass = classNameOf("ctaHover");
|
|
178
|
+
// The classes the markup APPLIES, not every mention in the document — the
|
|
179
|
+
// stylesheet names `.cs-cta-hover` too, and matching that would pass whether
|
|
180
|
+
// or not any element wears it.
|
|
181
|
+
const applied = (cta: NotificationElement) => {
|
|
182
|
+
const { html } = emailRenderer.render(
|
|
183
|
+
{
|
|
184
|
+
metadata: { kind: "auth.otp", title: "t" },
|
|
185
|
+
sections: [{ elements: [cta] }],
|
|
186
|
+
},
|
|
187
|
+
CONTEXT,
|
|
188
|
+
);
|
|
189
|
+
return [...html.matchAll(/class="([^"]+)"/g)].flatMap((match) =>
|
|
190
|
+
match[1].split(/\s+/),
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const linked = applied({
|
|
195
|
+
type: "callToAction",
|
|
196
|
+
label: "Open",
|
|
197
|
+
href: "https://example.com/a",
|
|
198
|
+
});
|
|
199
|
+
const payload = applied({ type: "callToAction", label: "123456" });
|
|
200
|
+
|
|
201
|
+
expect(linked).toContain(hoverClass);
|
|
202
|
+
expect(payload).not.toContain(hoverClass);
|
|
203
|
+
// Both still wear the resting edge.
|
|
204
|
+
expect(payload).toContain(classNameOf("cta"));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("leaves no raw colour anywhere else in the channel", () => {
|
|
208
|
+
// What stops `style="color:#888"` reappearing in chat.ts in six months. A
|
|
209
|
+
// test rather than a CI guard: guards live in company-semantics-ci and adding
|
|
210
|
+
// one is cross-repo plus its own ADR — revisit if this earns it.
|
|
211
|
+
const dir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
212
|
+
const hex = /#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/;
|
|
213
|
+
|
|
214
|
+
const sources = readdirSync(dir).filter(
|
|
215
|
+
(file) => file.endsWith(".ts") && file !== "colors.ts",
|
|
216
|
+
);
|
|
217
|
+
expect(sources.length).toBeGreaterThan(0);
|
|
218
|
+
|
|
219
|
+
for (const file of sources) {
|
|
220
|
+
const found = readFileSync(join(dir, file), "utf8").match(hex);
|
|
221
|
+
expect(
|
|
222
|
+
found?.[0],
|
|
223
|
+
`${file} states ${found?.[0]} directly — give it a role in colors.ts`,
|
|
224
|
+
).toBeUndefined();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|