@company-semantics/contracts 37.0.0 → 38.1.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 -2
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +2 -7
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +1965 -0
- package/src/notifications/content.ts +4 -5
- package/src/notifications/renderers/email/README.md +81 -52
- package/src/notifications/renderers/email/__tests__/amp.test.ts +225 -0
- package/src/notifications/renderers/email/__tests__/styles.test.ts +5 -5
- package/src/notifications/renderers/email/chat.ts +4 -5
- package/src/notifications/renderers/email/colors.ts +32 -20
- package/src/notifications/renderers/email/cta.ts +8 -6
- package/src/notifications/renderers/email/index.ts +22 -9
- package/src/notifications/renderers/email/render.ts +4 -2
- package/src/notifications/renderers/email/shells.ts +101 -15
- package/src/notifications/renderers/email/styles.ts +52 -10
- package/src/permissions/share-api.ts +40 -1
|
@@ -144,11 +144,10 @@ export interface Signature {
|
|
|
144
144
|
* `width`/`height` are the image's intrinsic pixel dimensions. They are optional
|
|
145
145
|
* because a channel that can measure an image itself does not need to be told, and
|
|
146
146
|
* requiring them would put a rendering constraint into channel-agnostic content.
|
|
147
|
-
* But a channel MAY be unable to draw one without them
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* them is what makes the image drawable everywhere.
|
|
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.
|
|
152
151
|
*/
|
|
153
152
|
export interface HeroImage {
|
|
154
153
|
type: "heroImage";
|
|
@@ -16,8 +16,8 @@ the markup. The strings are the same strings.
|
|
|
16
16
|
| `render.ts` | each `NotificationElement` → its class-only `<p>`/table markup, and spacing |
|
|
17
17
|
| `chat.ts` | `chatUnit` → HTML bubbles and plain-text box art |
|
|
18
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
|
-
| `styles.ts` | every declaration, as recipes — plus `inlineStyles`
|
|
19
|
+
| `shells.ts` | `htmlShell` / `ampShell` / `textShell` — what makes one email three surfaces |
|
|
20
|
+
| `styles.ts` | every declaration, as recipes — plus `inlineStyles` and `ampify` |
|
|
21
21
|
| `colors.ts` | every colour, by role, in every scheme — and the dark stylesheet |
|
|
22
22
|
| `constants.ts` | typography and layout (`MONO_DECL`, `FONT_SIZE`, `SPACING`, `CTA_PAD`) + `COMPANY_URL` |
|
|
23
23
|
| `escape-html.ts` | `escapeHtml` |
|
|
@@ -27,43 +27,58 @@ before it was deleted. The brand NAME is deliberately not among them: it is
|
|
|
27
27
|
`../../context`'s `COMPANY_NAME`, because it is the same answer on every channel
|
|
28
28
|
and renderers read it from `context.brand`.
|
|
29
29
|
|
|
30
|
-
##
|
|
30
|
+
## Three surfaces, one markup (ADR-CONTRACTS-089)
|
|
31
31
|
|
|
32
|
-
An email is a `multipart/alternative`: `text/plain
|
|
33
|
-
presentations of ONE message, and `RenderedEmail`
|
|
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.
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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>`.
|
|
38
46
|
|
|
39
47
|
`EmailLine` gains no third field: `../../renderer.ts` warns against exactly that,
|
|
40
48
|
and a surface is a way of SPENDING the lines rather than a thing a line carries.
|
|
41
|
-
The cost is that `EmailLine.html` is an intermediate representation —
|
|
42
|
-
|
|
43
|
-
legible.
|
|
44
|
-
|
|
45
|
-
###
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
part
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
### ⚠️ The AMP body is NEVER SENT
|
|
54
|
+
|
|
55
|
+
Two of the three ship. `amp` is rendered, previewed in the Ladle gallery, and
|
|
56
|
+
dropped — and **delivering it is a regression, not a feature**. The backend's
|
|
57
|
+
`EmailDelivery.test.ts` fails if it reaches the transport; that test is the
|
|
58
|
+
invariant, this section is its explanation.
|
|
59
|
+
|
|
60
|
+
**Why not sent.** Gmail iOS inverts the `text/html` part post-delivery and does NOT
|
|
61
|
+
touch a `text/x-amp-html` part. That inversion is the only dark mode a Gmail iOS
|
|
62
|
+
reader gets — no Gmail client honours `prefers-color-scheme` on any surface — so an
|
|
63
|
+
AMP part would switch it off and hand them a white email in a dark client. AMP buys
|
|
64
|
+
`:hover` in Gmail and would cost dark mode in Gmail. Measured, not argued:
|
|
65
|
+
`company-semantics-backend/docs/dark-mode-probe.md` (ADR-CONTRACTS-091, -092).
|
|
66
|
+
|
|
67
|
+
**Why it exists.** AMP is the only email format with a real animation model —
|
|
68
|
+
`amp-animation`, `amp-bind`, `amp-position-observer`, no arbitrary JS. Nothing else
|
|
69
|
+
can make the kaomoji companions blink once, three seconds after open. Keeping the
|
|
70
|
+
surface alive costs one inert field; rebuilding it later costs more. If Google ever
|
|
71
|
+
allows `prefers-color-scheme` here, or Gmail starts inverting AMP too, the trade
|
|
72
|
+
flips and this is ready.
|
|
73
|
+
|
|
74
|
+
**Light-only, permanently.** `prefers-color-scheme` in `<style amp-custom>` is a hard
|
|
75
|
+
validator error (explicit 14-feature allowlist, frozen since 2023, unchanged by
|
|
76
|
+
`data-css-strict`). The `media` ATTRIBUTE takes the same feature, PASSES the
|
|
77
|
+
validator — and Gmail's runtime ignores it. Both doors closed.
|
|
78
|
+
|
|
79
|
+
`pnpm validate:amp` runs the real AMP validator. `__tests__/amp.test.ts` asserts
|
|
80
|
+
the constraints we KNOW on every run, which is not the same claim: it passed once
|
|
81
|
+
while every fixture was invalid.
|
|
67
82
|
|
|
68
83
|
## Colour — the thing this channel has and the others do not
|
|
69
84
|
|
|
@@ -153,22 +168,32 @@ expect to style it properly rather than trust the placeholder.
|
|
|
153
168
|
`__tests__/colors.test.ts` fails on a raw hex anywhere else in the directory,
|
|
154
169
|
including one in a comment, because a comment naming a literal goes stale the
|
|
155
170
|
first time the palette is tuned.
|
|
156
|
-
-
|
|
157
|
-
than duplication: one source each for two schemes, both from
|
|
158
|
-
dark declaration carries `!important` because the inline light
|
|
159
|
-
otherwise win on specificity and dark would silently do nothing.
|
|
171
|
+
- On the HTML surface, light is inline and dark is the `<style>` block, and that is
|
|
172
|
+
the mechanism rather than duplication: one source each for two schemes, both from
|
|
173
|
+
`colors.ts`. Every dark declaration carries `!important` because the inline light
|
|
174
|
+
colour would otherwise win on specificity and dark would silently do nothing. On
|
|
175
|
+
the AMP surface there is no inline style, so the recipes carry light and source
|
|
176
|
+
order alone would resolve a cascade — which is moot, because AMP has no dark.
|
|
160
177
|
- `:hover` is the one exception, because it has no inline form: BOTH schemes state
|
|
161
178
|
it in the stylesheet (`BASE_STYLE` light, `DARK_STYLE` dark, resolved by source
|
|
162
|
-
order).
|
|
163
|
-
not — and needs no fallback, because without it the button
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
179
|
+
order). On the HTML surface it is opportunistic — Apple Mail honours it, Gmail and
|
|
180
|
+
classic Outlook do not — and needs no fallback, because without it the button
|
|
181
|
+
simply rests. It is also the ONE rule the AMP surface shares, via
|
|
182
|
+
`hoverStyle(false)`; AMP forbids `!important`, which is why that template takes a
|
|
183
|
+
parameter and `DARK_STYLE` does not.
|
|
184
|
+
- The AMP surface has NO dark scheme, and that is why it is never SENT. AMP4EMAIL
|
|
185
|
+
disallows the `prefers-color-scheme` media feature (an explicit 14-feature
|
|
186
|
+
allowlist, unchanged by `data-css-strict`), so it cannot have one. ADR-CONTRACTS-089
|
|
187
|
+
called that free — "AMP's readers force-invert and never got our dark colours
|
|
188
|
+
anyway". Half right: they never got them, because no Gmail client honours the media
|
|
189
|
+
feature on any surface. But Gmail web recolours nothing, Yahoo does not invert, and
|
|
190
|
+
Gmail iOS inverts the html part while EXEMPTING an AMP part — so an AMP part does
|
|
191
|
+
not lose a scheme its readers never had, it switches off the inversion that IS
|
|
192
|
+
their dark mode. Measured; see the AMP section above.
|
|
193
|
+
- Role hooks ride the AMP surface unstyled (`cs-faint`, `cs-bubble`, `cs-cta`,
|
|
194
|
+
`cs-link`), because their only rules live in `DARK_STYLE`. Their colours arrive
|
|
195
|
+
via each recipe's `roleDecl`, so this is dead bytes rather than a bug — the price
|
|
196
|
+
of one markup. `.cs-cta-hover` is the exception: it is the feature.
|
|
172
197
|
- A hover hook goes on a CTA ONLY when it has an `href`. An unlinked label is a
|
|
173
198
|
payload (an OTP code), and a hover state on it would promise a click that does
|
|
174
199
|
not exist — the same invariant that keeps it a `<span>` rather than an `<a>`.
|
|
@@ -181,7 +206,11 @@ expect to style it properly rather than trust the placeholder.
|
|
|
181
206
|
promise: made alone it stops Apple Mail protecting our colours while leaving
|
|
182
207
|
light text on a dark background — worse than declaring light-only. This channel
|
|
183
208
|
WAS light-only by construction until ADR-CONTRACTS-088; it is not any more.
|
|
184
|
-
- Dark reaches Apple Mail, iOS Mail and Outlook for Mac. Gmail
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
209
|
+
- Dark reaches Apple Mail, iOS Mail and Outlook for Mac, and nobody else. Gmail
|
|
210
|
+
honours none of it on any surface, so a Gmail reader's dark mode is whatever their
|
|
211
|
+
client does to our light colours unaided: Gmail iOS inverts the html part, and
|
|
212
|
+
**Gmail web does nothing at all** — a light email in a dark client. That gap is
|
|
213
|
+
real and open, and the lever is inversion-indifferent authoring in `colors.ts`
|
|
214
|
+
(mid-tones, no pure `#ffffff`/`#000000` in large fields, solid-fill CTAs), not
|
|
215
|
+
another surface. The dark path is additive: a client that strips `<style>` keeps
|
|
216
|
+
the inline light colours, which is the status quo rather than a degradation.
|
|
@@ -0,0 +1,225 @@
|
|
|
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 (an explicit 14-feature allowlist in the validator's
|
|
178
|
+
// protoascii, `issues_as_error: true`, unchanged by `data-css-strict`, frozen
|
|
179
|
+
// since 2023), so this surface cannot have dark in any form.
|
|
180
|
+
//
|
|
181
|
+
// Nor can it via the back door: the `media` ATTRIBUTE on an AMP element takes
|
|
182
|
+
// the same feature and PASSES the validator — and Gmail's runtime ignores it,
|
|
183
|
+
// rendering a dark-gated and a light-gated block at once. Measured; see
|
|
184
|
+
// `company-semantics-backend/docs/dark-mode-probe.md`. Both doors are closed.
|
|
185
|
+
//
|
|
186
|
+
// The surface being light-only is exactly why it is never SENT: an AMP part
|
|
187
|
+
// switches off Gmail iOS's inversion, which is the only dark mode those readers
|
|
188
|
+
// get (ADR-CONTRACTS-091/-092). It is rendered for a future that needs AMP's
|
|
189
|
+
// animation model, not for delivery.
|
|
190
|
+
//
|
|
191
|
+
// This test exists so that "add dark mode to AMP" fails here with the reason,
|
|
192
|
+
// rather than in `pnpm validate:amp` with a CSS syntax error.
|
|
193
|
+
for (const { key, amp } of RENDERED) {
|
|
194
|
+
expect(amp, `${key} would be invalid AMP`).not.toContain(
|
|
195
|
+
"prefers-color-scheme",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("ampify", () => {
|
|
201
|
+
it("gives a sized image explicit layout", () => {
|
|
202
|
+
expect(
|
|
203
|
+
ampify(
|
|
204
|
+
'<img src="a.png" alt="A" width="600" height="200" class="csr-hero-normal">',
|
|
205
|
+
),
|
|
206
|
+
).toBe(
|
|
207
|
+
'<amp-img src="a.png" alt="A" width="600" height="200" class="csr-hero-normal" layout="intrinsic"></amp-img>',
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("degrades an unsized image to its alt text, keeping its spacing", () => {
|
|
212
|
+
// `../../content.ts` makes dimensions optional, so this surface must have an
|
|
213
|
+
// answer for their absence. `alt` is required exactly so that a channel with
|
|
214
|
+
// nothing else has something to say.
|
|
215
|
+
expect(
|
|
216
|
+
ampify('<img src="a.png" alt="A logo" class="csr-hero-tight">'),
|
|
217
|
+
).toBe('<p class="csr-p-tight">A logo</p>');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("leaves markup with no image alone", () => {
|
|
221
|
+
const plain = '<p class="csr-p-none">x</p>';
|
|
222
|
+
expect(ampify(plain)).toBe(plain);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -19,7 +19,7 @@ import { describe, expect, it } from "vitest";
|
|
|
19
19
|
import type { NotificationContent } from "../../../content";
|
|
20
20
|
import type { RenderContext } from "../../../context";
|
|
21
21
|
import { emailRenderer } from "../index";
|
|
22
|
-
import { inlineOf, inlineStyles, STYLE_NAMES } from "../styles";
|
|
22
|
+
import { inlineOf, inlineStyles, ruleOf, STYLE_NAMES } from "../styles";
|
|
23
23
|
|
|
24
24
|
const CONTEXT: RenderContext = {
|
|
25
25
|
brand: { name: "Company Semantics", copyrightYear: 2026 },
|
|
@@ -100,11 +100,11 @@ describe("the style registry", () => {
|
|
|
100
100
|
expect(inlineStyles(plain)).toBe(plain);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
-
it("states
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
// a shape this registry needs to keep proving it can take.
|
|
103
|
+
it("states each recipe as a rule and as an inline style, from one source", () => {
|
|
104
|
+
// The two surfaces' only difference is the shape, never the content — which is
|
|
105
|
+
// the claim that lets `ampShell` and `htmlShell` share this registry.
|
|
107
106
|
for (const name of STYLE_NAMES) {
|
|
107
|
+
expect(ruleOf(name)).toBe(`.csr-${name} { ${inlineOf(name)} }`);
|
|
108
108
|
expect(inlineOf(name).endsWith(";")).toBe(true);
|
|
109
109
|
}
|
|
110
110
|
});
|
|
@@ -57,11 +57,10 @@ const CHAT_INDENT = " ";
|
|
|
57
57
|
* The gap under a chat row: 24px clears the bubbles, 16px hugs whatever the row
|
|
58
58
|
* is introducing (a CTA, the dots).
|
|
59
59
|
*
|
|
60
|
-
* A recipe name rather than a margin string
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* a call site.
|
|
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.
|
|
65
64
|
*/
|
|
66
65
|
type ChatRow = "chat-row-16" | "chat-row-24";
|
|
67
66
|
|
|
@@ -275,22 +275,23 @@ const dark = PALETTES.dark;
|
|
|
275
275
|
* is no fallback to write: without it the button rests, which is how it looks
|
|
276
276
|
* today. Purely additive, like the dark path.
|
|
277
277
|
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
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.
|
|
278
|
+
* This is the ONE role whose reach the AMP surface would change — Gmail honours
|
|
279
|
+
* `:hover` there — and it is why that surface exists. It is NOT why the surface is
|
|
280
|
+
* unsent: shipping it would cost Gmail iOS its dark mode, which is worth more than
|
|
281
|
+
* a hover state (ADR-CONTRACTS-091/-092). The `important` parameter is what lets
|
|
282
|
+
* one rule serve both shells; see `./shells.ts`.
|
|
288
283
|
*
|
|
289
284
|
* `.cs-cta-hover` is present ONLY on a CTA that actually has an `href` — see
|
|
290
285
|
* `./cta.ts`. An OTP code wears `.cs-cta` for its edge and never this, because a
|
|
291
286
|
* hover state on something unclickable promises a click that will not happen.
|
|
292
287
|
*/
|
|
293
|
-
export
|
|
288
|
+
export function hoverStyle(important: boolean): string {
|
|
289
|
+
const bang = important ? " !important" : "";
|
|
290
|
+
return `.cs-cta-hover:hover { background-color: ${light.ctaHover}${bang}; }`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The html surface's hover block. See `hoverStyle` for why it is `important`. */
|
|
294
|
+
export const BASE_STYLE = hoverStyle(true);
|
|
294
295
|
|
|
295
296
|
/**
|
|
296
297
|
* The dark override.
|
|
@@ -304,15 +305,26 @@ export const BASE_STYLE = `.cs-cta-hover:hover { background-color: ${light.ctaHo
|
|
|
304
305
|
* colour is inline, inline beats a stylesheet on specificity, and without
|
|
305
306
|
* `!important` every rule here silently loses and dark quietly does nothing.
|
|
306
307
|
*
|
|
307
|
-
* **
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
* `
|
|
308
|
+
* **This block is the html surface's alone, and that is why the AMP surface is
|
|
309
|
+
* never sent.** AMP4EMAIL disallows the `prefers-color-scheme` media feature
|
|
310
|
+
* outright — a hard validator error, checked with and without `data-css-strict` —
|
|
311
|
+
* so the AMP surface cannot carry these rules in any form (ADR-CONTRACTS-089).
|
|
312
|
+
* Unlike `hoverStyle`, there was no version of this worth parameterizing.
|
|
313
|
+
*
|
|
314
|
+
* ADR-CONTRACTS-089 called that free, on the grounds that AMP's readers force-invert
|
|
315
|
+
* and never received these colours anyway. **The first half is true and the second is
|
|
316
|
+
* false.** Nobody in Gmail or Yahoo honours `prefers-color-scheme`, so this block
|
|
317
|
+
* genuinely never reached them — but Gmail web recolours nothing, Yahoo does not
|
|
318
|
+
* invert, and Gmail iOS inverts the html part while EXEMPTING an AMP part. So an AMP
|
|
319
|
+
* part does not lose a scheme its readers never got; it switches off the client-side
|
|
320
|
+
* inversion that IS their dark mode. Measured in
|
|
321
|
+
* `company-semantics-backend/docs/dark-mode-probe.md`; the surface stays unsent
|
|
322
|
+
* (ADR-CONTRACTS-091/-092).
|
|
323
|
+
*
|
|
324
|
+
* **Which leaves a real gap this block cannot close.** Gmail web recolours nothing
|
|
325
|
+
* and honours nothing, so a light email stays light in a dark client. The lever there
|
|
326
|
+
* is inversion-indifferent authoring in this file — mid-tones, no pure `#ffffff` /
|
|
327
|
+
* `#000000` in large fields, solid-fill CTAs — not another surface.
|
|
316
328
|
*
|
|
317
329
|
* `.cs-faint` states both `color` and `border-top-color` because one role lands
|
|
318
330
|
* on two properties — the `/* EOM *\/` span takes the first and ignores the
|
|
@@ -41,8 +41,10 @@ const CTA_BOX_PAD = 3;
|
|
|
41
41
|
* it survives `inlineStyles` untouched — and its `!important` still beats the
|
|
42
42
|
* inline padding, exactly as before.
|
|
43
43
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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`.
|
|
46
48
|
*/
|
|
47
49
|
export const MSO_CTA_STYLE = `.cs-cta td { padding: ${CTA_PAD} !important; }
|
|
48
50
|
.cs-cta a { padding: 0 !important; }`;
|
|
@@ -68,10 +70,10 @@ export function ctaButton(
|
|
|
68
70
|
|
|
69
71
|
// The hover hook goes on ONLY when there is somewhere to go. An OTP code is a
|
|
70
72
|
// box with no link in it, and lighting it up under the pointer would promise a
|
|
71
|
-
// click that does not exist. `:hover` is opportunistic — see
|
|
72
|
-
// `
|
|
73
|
-
//
|
|
74
|
-
//
|
|
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).
|
|
75
77
|
const boxClass = href
|
|
76
78
|
? styleClass(`cta-box-${margin}`, "cta", "ctaHover")
|
|
77
79
|
: styleClass(`cta-box-${margin}`, "cta");
|
|
@@ -25,20 +25,19 @@ import type { Renderer } from "../../renderer";
|
|
|
25
25
|
|
|
26
26
|
import type { Spacing } from "./constants";
|
|
27
27
|
import { renderElement } from "./render";
|
|
28
|
-
import { type EmailLine, htmlShell, textShell } from "./shells";
|
|
28
|
+
import { ampShell, type EmailLine, htmlShell, textShell } from "./shells";
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* One rendered email — the email channel's natural output type.
|
|
32
32
|
*
|
|
33
|
-
*
|
|
34
|
-
* parts a client chooses between.
|
|
35
|
-
*
|
|
33
|
+
* Three bodies, because an email IS a `multipart/alternative`: one message, whose
|
|
34
|
+
* parts a client chooses between. This is NOT three channels — a recipient gets one
|
|
35
|
+
* email — which is why AMP lives here rather than behind a second `Renderer`
|
|
36
|
+
* (ADR-CONTRACTS-089).
|
|
36
37
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* part, so an AMP part opts those readers OUT of the only dark scheme they receive.
|
|
41
|
-
* That is measured, not argued — `company-semantics-backend/docs/dark-mode-probe.md`.
|
|
38
|
+
* **Two of them ship.** Assembling the parts is delivery's job, and delivery is not
|
|
39
|
+
* contracts' — but the `amp` body is not merely un-plumbed, it is deliberately unsent
|
|
40
|
+
* and must stay that way (ADR-CONTRACTS-092). See the field.
|
|
42
41
|
*/
|
|
43
42
|
export interface RenderedEmail {
|
|
44
43
|
/** Subject line — the notification's `metadata.title`. */
|
|
@@ -47,6 +46,19 @@ export interface RenderedEmail {
|
|
|
47
46
|
text: string;
|
|
48
47
|
/** HTML body (`text/html`) — every kind is multi-output. */
|
|
49
48
|
html: string;
|
|
49
|
+
/**
|
|
50
|
+
* AMP4EMAIL body (`text/x-amp-html`).
|
|
51
|
+
*
|
|
52
|
+
* ⚠️ **Rendered, and deliberately NEVER SENT** (ADR-CONTRACTS-092). Delivering it
|
|
53
|
+
* is a regression: Gmail iOS inverts the html part and does NOT touch an AMP
|
|
54
|
+
* part, so an AMP part switches off the only dark mode a Gmail iOS reader gets.
|
|
55
|
+
* `EmailDelivery.test.ts` in the backend fails if this reaches the transport.
|
|
56
|
+
*
|
|
57
|
+
* It is kept alive for AMP's animation model (`amp-animation`, `amp-bind`),
|
|
58
|
+
* which no other email format has, and which is the one thing that could make it
|
|
59
|
+
* worth sending. See `./shells.ts`'s `ampShell`.
|
|
60
|
+
*/
|
|
61
|
+
amp: string;
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
/**
|
|
@@ -80,6 +92,7 @@ export const emailRenderer: Renderer<RenderedEmail> = {
|
|
|
80
92
|
subject: content.metadata.title,
|
|
81
93
|
text: textShell(lines),
|
|
82
94
|
html: htmlShell(lines),
|
|
95
|
+
amp: ampShell(lines),
|
|
83
96
|
};
|
|
84
97
|
},
|
|
85
98
|
};
|