@company-semantics/contracts 36.0.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 +2 -4
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +0 -1965
- package/src/notifications/content.ts +5 -4
- package/src/notifications/renderers/email/README.md +45 -47
- package/src/notifications/renderers/email/__tests__/styles.test.ts +5 -5
- package/src/notifications/renderers/email/chat.ts +5 -4
- package/src/notifications/renderers/email/colors.ts +25 -22
- package/src/notifications/renderers/email/cta.ts +6 -8
- package/src/notifications/renderers/email/index.ts +10 -13
- package/src/notifications/renderers/email/render.ts +2 -4
- package/src/notifications/renderers/email/shells.ts +15 -78
- package/src/notifications/renderers/email/styles.ts +10 -52
- package/src/notifications/renderers/email/__tests__/amp.test.ts +0 -217
|
@@ -144,10 +144,11 @@ 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
|
-
*
|
|
147
|
+
* But a channel MAY be unable to draw one without them, and the constraint is real
|
|
148
|
+
* even though the surface that first raised it is gone: AMP's `<amp-img>` required
|
|
149
|
+
* explicit dimensions to reserve layout, and the email channel's AMP surface fell
|
|
150
|
+
* back to the `alt` text without them (retired in ADR-CONTRACTS-091). Supplying
|
|
151
|
+
* them is what makes the image drawable everywhere.
|
|
151
152
|
*/
|
|
152
153
|
export interface HeroImage {
|
|
153
154
|
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` / `
|
|
20
|
-
| `styles.ts` | every declaration, as recipes — plus `inlineStyles`
|
|
19
|
+
| `shells.ts` | `htmlShell` / `textShell` — what makes one email two surfaces |
|
|
20
|
+
| `styles.ts` | every declaration, as recipes — plus `inlineStyles` |
|
|
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,38 +27,43 @@ 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
|
+
## Two surfaces, one markup (ADR-CONTRACTS-089, -091)
|
|
31
31
|
|
|
32
|
-
An email is a `multipart/alternative`: `text/plain
|
|
33
|
-
|
|
34
|
-
carries all three. AMP is not a fourth channel — a recipient gets one email.
|
|
32
|
+
An email is a `multipart/alternative`: `text/plain` and `text/html` are two
|
|
33
|
+
presentations of ONE message, and `RenderedEmail` carries both.
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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>`.
|
|
35
|
+
`render.ts`, `cta.ts` and `chat.ts` emit CLASS-ONLY markup, and `htmlShell` runs
|
|
36
|
+
`inlineStyles`, which turns recipe classes back into the exact inline styles this
|
|
37
|
+
channel has always shipped.
|
|
46
38
|
|
|
47
39
|
`EmailLine` gains no third field: `../../renderer.ts` warns against exactly that,
|
|
48
40
|
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
|
|
50
|
-
|
|
51
|
-
|
|
41
|
+
The cost is that `EmailLine.html` is an intermediate representation — `render.ts`
|
|
42
|
+
does not read as the bytes it emits, and the snapshot is where the real markup is
|
|
43
|
+
legible.
|
|
44
|
+
|
|
45
|
+
### There was a third surface, and removing it is the point
|
|
46
|
+
|
|
47
|
+
`text/x-amp-html` shipped in 36.0.0 and was retired in 37.0.0. The class-only split
|
|
48
|
+
above exists BECAUSE of it — AMP forbids inline `style`, the html surface is built
|
|
49
|
+
from it — and the split stays: the registry is now the single source for a recipe,
|
|
50
|
+
and the markup is byte-identical either way (removing AMP changed the snapshot by
|
|
51
|
+
1965 deletions and **zero** insertions).
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
AMP bought `:hover` in Gmail. It cost Gmail iOS its dark mode, and we did not know
|
|
54
|
+
that when we shipped it: **Gmail iOS inverts the html part and does NOT touch an AMP
|
|
55
|
+
part**, so having one opts those readers out of the only dark scheme they get. That
|
|
56
|
+
is measured, not argued — `company-semantics-backend/docs/dark-mode-probe.md`, and
|
|
57
|
+
it is a fact the public record does not contain. A hover state is worth less than a
|
|
58
|
+
legible email (ADR-CONTRACTS-091).
|
|
55
59
|
|
|
56
|
-
|
|
57
|
-
media feature outright. It costs nothing — see Colour.
|
|
60
|
+
Two things we learned the expensive way, so nobody re-derives them:
|
|
58
61
|
|
|
59
|
-
`
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
- `prefers-color-scheme` in `<style amp-custom>` is a hard AMP4EMAIL validator
|
|
63
|
+
ERROR. Real, permanent, frozen since 2023.
|
|
64
|
+
- The `media` ATTRIBUTE on an AMP element takes the same feature and PASSES the
|
|
65
|
+
validator — and Gmail's runtime ignores it entirely, rendering both a dark-gated
|
|
66
|
+
and a light-gated block. Validator-legal, runtime-inert. That door is closed.
|
|
62
67
|
|
|
63
68
|
## Colour — the thing this channel has and the others do not
|
|
64
69
|
|
|
@@ -148,29 +153,22 @@ expect to style it properly rather than trust the placeholder.
|
|
|
148
153
|
`__tests__/colors.test.ts` fails on a raw hex anywhere else in the directory,
|
|
149
154
|
including one in a comment, because a comment naming a literal goes stale the
|
|
150
155
|
first time the palette is tuned.
|
|
151
|
-
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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.
|
|
156
|
+
- Light is inline and dark is the `<style>` block, and that is the mechanism rather
|
|
157
|
+
than duplication: one source each for two schemes, both from `colors.ts`. Every
|
|
158
|
+
dark declaration carries `!important` because the inline light colour would
|
|
159
|
+
otherwise win on specificity and dark would silently do nothing.
|
|
157
160
|
- `:hover` is the one exception, because it has no inline form: BOTH schemes state
|
|
158
161
|
it in the stylesheet (`BASE_STYLE` light, `DARK_STYLE` dark, resolved by source
|
|
159
|
-
order).
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
the
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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.
|
|
162
|
+
order). It is opportunistic — Apple Mail honours it, Gmail and classic Outlook do
|
|
163
|
+
not — and needs no fallback, because without it the button simply rests.
|
|
164
|
+
- **`DARK_STYLE` reaches Apple Mail, iOS Mail and Outlook for Mac, and nobody else.**
|
|
165
|
+
No Gmail client honours `prefers-color-scheme` on any surface; Yahoo rewrites the
|
|
166
|
+
query. A Gmail reader's dark mode is whatever their client does to our light
|
|
167
|
+
colours unaided: Gmail iOS inverts the html part, Gmail web does nothing at all.
|
|
168
|
+
Closing the Gmail-web gap means inversion-indifferent authoring HERE — mid-tones,
|
|
169
|
+
no pure `#ffffff`/`#000000` in large fields, solid-fill CTAs — not another surface.
|
|
170
|
+
- Role hooks (`cs-faint`, `cs-bubble`, `cs-cta`, `cs-link`) carry their colours via
|
|
171
|
+
each recipe's `roleDecl`; their only stylesheet rules live in `DARK_STYLE`.
|
|
174
172
|
- A hover hook goes on a CTA ONLY when it has an `href`. An unlinked label is a
|
|
175
173
|
payload (an OTP code), and a hover state on it would promise a click that does
|
|
176
174
|
not exist — the same invariant that keeps it a `<span>` rather than an `<a>`.
|
|
@@ -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,
|
|
22
|
+
import { inlineOf, inlineStyles, 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
|
-
//
|
|
103
|
+
it("states every recipe as an inline style, from one source", () => {
|
|
104
|
+
// `ruleOf` — the same recipe as a CSS rule — went with the AMP surface
|
|
105
|
+
// (ADR-CONTRACTS-091). It had no other caller, and a rule nobody states is not
|
|
106
|
+
// a shape this registry needs to keep proving it can take.
|
|
106
107
|
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,10 +57,11 @@ 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
|
-
* new string appearing at
|
|
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.
|
|
64
65
|
*/
|
|
65
66
|
type ChatRow = "chat-row-16" | "chat-row-24";
|
|
66
67
|
|
|
@@ -270,24 +270,27 @@ const dark = PALETTES.dark;
|
|
|
270
270
|
* the LIGHT hover; the dark block follows it in the shell and overrides it, so
|
|
271
271
|
* source order is what resolves them (same specificity).
|
|
272
272
|
*
|
|
273
|
-
* Whether any of it happens is the client's call
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
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.
|
|
279
288
|
*
|
|
280
289
|
* `.cs-cta-hover` is present ONLY on a CTA that actually has an `href` — see
|
|
281
290
|
* `./cta.ts`. An OTP code wears `.cs-cta` for its edge and never this, because a
|
|
282
291
|
* hover state on something unclickable promises a click that will not happen.
|
|
283
292
|
*/
|
|
284
|
-
export
|
|
285
|
-
const bang = important ? " !important" : "";
|
|
286
|
-
return `.cs-cta-hover:hover { background-color: ${light.ctaHover}${bang}; }`;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/** The html surface's hover block. See `hoverStyle` for why it is `important`. */
|
|
290
|
-
export const BASE_STYLE = hoverStyle(true);
|
|
293
|
+
export const BASE_STYLE = `.cs-cta-hover:hover { background-color: ${light.ctaHover} !important; }`;
|
|
291
294
|
|
|
292
295
|
/**
|
|
293
296
|
* The dark override.
|
|
@@ -301,15 +304,15 @@ export const BASE_STYLE = hoverStyle(true);
|
|
|
301
304
|
* colour is inline, inline beats a stylesheet on specificity, and without
|
|
302
305
|
* `!important` every rule here silently loses and dark quietly does nothing.
|
|
303
306
|
*
|
|
304
|
-
* **
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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.
|
|
313
316
|
*
|
|
314
317
|
* `.cs-faint` states both `color` and `border-top-color` because one role lands
|
|
315
318
|
* on two properties — the `/* EOM *\/` span takes the first and ignores the
|
|
@@ -41,10 +41,8 @@ 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
|
-
*
|
|
46
|
-
* conditional comments and `!important`. Nothing has to strip it — it lives in the
|
|
47
|
-
* `<head>`, not in an `EmailLine`.
|
|
44
|
+
* It lives in the `<head>`, not in an `EmailLine`, which is why no surface ever had
|
|
45
|
+
* to strip it.
|
|
48
46
|
*/
|
|
49
47
|
export const MSO_CTA_STYLE = `.cs-cta td { padding: ${CTA_PAD} !important; }
|
|
50
48
|
.cs-cta a { padding: 0 !important; }`;
|
|
@@ -70,10 +68,10 @@ export function ctaButton(
|
|
|
70
68
|
|
|
71
69
|
// The hover hook goes on ONLY when there is somewhere to go. An OTP code is a
|
|
72
70
|
// box with no link in it, and lighting it up under the pointer would promise a
|
|
73
|
-
// click that does not exist.
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
71
|
+
// click that does not exist. `:hover` is opportunistic — see `./colors.ts`'s
|
|
72
|
+
// `BASE_STYLE` — so it adds a state in Apple Mail and changes nothing in Gmail.
|
|
73
|
+
// Reaching Gmail with it was the AMP surface's whole purpose, and cost Gmail iOS
|
|
74
|
+
// its dark mode to do it (ADR-CONTRACTS-091).
|
|
77
75
|
const boxClass = href
|
|
78
76
|
? styleClass(`cta-box-${margin}`, "cta", "ctaHover")
|
|
79
77
|
: styleClass(`cta-box-${margin}`, "cta");
|
|
@@ -25,17 +25,20 @@ import type { Renderer } from "../../renderer";
|
|
|
25
25
|
|
|
26
26
|
import type { Spacing } from "./constants";
|
|
27
27
|
import { renderElement } from "./render";
|
|
28
|
-
import {
|
|
28
|
+
import { 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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* `
|
|
33
|
+
* Two bodies, because an email IS a `multipart/alternative`: one message, whose
|
|
34
|
+
* parts a client chooses between. Assembling the parts is delivery's job, and
|
|
35
|
+
* delivery is not contracts'.
|
|
36
|
+
*
|
|
37
|
+
* There was a third — `amp`, an AMP4EMAIL body — and it is gone
|
|
38
|
+
* (ADR-CONTRACTS-091). It bought `:hover` on the CTA for Gmail readers and cost
|
|
39
|
+
* them their dark mode: Gmail iOS inverts the html part and does NOT touch the AMP
|
|
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`.
|
|
39
42
|
*/
|
|
40
43
|
export interface RenderedEmail {
|
|
41
44
|
/** Subject line — the notification's `metadata.title`. */
|
|
@@ -44,11 +47,6 @@ export interface RenderedEmail {
|
|
|
44
47
|
text: string;
|
|
45
48
|
/** HTML body (`text/html`) — every kind is multi-output. */
|
|
46
49
|
html: string;
|
|
47
|
-
/**
|
|
48
|
-
* AMP4EMAIL body (`text/x-amp-html`) — read by Gmail, Yahoo and Mail.ru, and
|
|
49
|
-
* the only surface where the CTA's `:hover` reaches most recipients.
|
|
50
|
-
*/
|
|
51
|
-
amp: string;
|
|
52
50
|
}
|
|
53
51
|
|
|
54
52
|
/**
|
|
@@ -82,7 +80,6 @@ export const emailRenderer: Renderer<RenderedEmail> = {
|
|
|
82
80
|
subject: content.metadata.title,
|
|
83
81
|
text: textShell(lines),
|
|
84
82
|
html: htmlShell(lines),
|
|
85
|
-
amp: ampShell(lines),
|
|
86
83
|
};
|
|
87
84
|
},
|
|
88
85
|
};
|
|
@@ -216,10 +216,8 @@ export function renderElement(
|
|
|
216
216
|
];
|
|
217
217
|
|
|
218
218
|
case "heroImage": {
|
|
219
|
-
// Stated as attributes, not just style: they are the image's intrinsic
|
|
220
|
-
//
|
|
221
|
-
// `./styles.ts`'s `ampify` reads them to decide whether the AMP surface can
|
|
222
|
-
// draw an `<amp-img>` at all.
|
|
219
|
+
// Stated as attributes, not just style: they are the image's intrinsic size
|
|
220
|
+
// and every client uses them to reserve space before it loads.
|
|
223
221
|
const dimensions =
|
|
224
222
|
element.width !== undefined && element.height !== undefined
|
|
225
223
|
? ` width="${element.width}" height="${element.height}"`
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* composes ONE list of `EmailLine`s and both surfaces derive from it, so the two
|
|
7
7
|
* can never disagree about what the email says.
|
|
8
8
|
*
|
|
9
|
+
* There was a third shell — `ampShell` — retired in ADR-CONTRACTS-091. See
|
|
10
|
+
* `./index.ts`: the AMP part cost Gmail readers their dark mode.
|
|
11
|
+
*
|
|
9
12
|
* INVARIANTS:
|
|
10
13
|
* - Pure functions of their input lines. No clock, no environment, no I/O.
|
|
11
14
|
* - These strings are the email's actual markup, and
|
|
@@ -13,16 +16,10 @@
|
|
|
13
16
|
* character-for-character.
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
|
-
import { BASE_STYLE, DARK_STYLE
|
|
19
|
+
import { BASE_STYLE, DARK_STYLE } from "./colors";
|
|
17
20
|
import type { Spacing } from "./constants";
|
|
18
21
|
import { MSO_CTA_STYLE } from "./cta";
|
|
19
|
-
import {
|
|
20
|
-
AMP_RECIPE_RULES,
|
|
21
|
-
ampify,
|
|
22
|
-
inlineOf,
|
|
23
|
-
inlineStyles,
|
|
24
|
-
styleClass,
|
|
25
|
-
} from "./styles";
|
|
22
|
+
import { inlineOf, inlineStyles } from "./styles";
|
|
26
23
|
|
|
27
24
|
/**
|
|
28
25
|
* One rendered line — both presentations of one thing the notification says,
|
|
@@ -48,11 +45,16 @@ export interface EmailLine {
|
|
|
48
45
|
* protecting our colours, apply its dark background, and leave the inline LIGHT
|
|
49
46
|
* text colour sitting on top of it. The two ship together or not at all.
|
|
50
47
|
*
|
|
51
|
-
* Only Apple Mail, iOS Mail and Outlook for Mac read any of this.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
48
|
+
* Only Apple Mail, iOS Mail and Outlook for Mac read any of this. No Gmail client
|
|
49
|
+
* honours `prefers-color-scheme` on any surface; what Gmail iOS does instead is
|
|
50
|
+
* invert this part post-delivery, which nothing here can stop — and which is why
|
|
51
|
+
* the body still states an explicit background rather than trusting a default.
|
|
52
|
+
* That inversion is not a fallback we tolerate, it is the ONLY dark mode a Gmail
|
|
53
|
+
* iOS reader gets, and preserving it is why the AMP surface was retired
|
|
54
|
+
* (ADR-CONTRACTS-091): Gmail leaves an AMP part alone, so having one would have
|
|
55
|
+
* turned this inversion off. Measured in
|
|
56
|
+
* `company-semantics-backend/docs/dark-mode-probe.md` — Gmail web, by contrast,
|
|
57
|
+
* recolours nothing at all.
|
|
56
58
|
*
|
|
57
59
|
* The `<!--[if mso]>` block is the mirror image: it is read ONLY by the Outlook
|
|
58
60
|
* Word engine, and undoes the one layout that engine cannot render — a linked
|
|
@@ -85,71 +87,6 @@ ${inner}
|
|
|
85
87
|
</html>`;
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
/**
|
|
89
|
-
* Wrap rendered lines in the AMP4EMAIL shell (ADR-CONTRACTS-089).
|
|
90
|
-
*
|
|
91
|
-
* The third MIME part of the same email — `text/x-amp-html`, beside `text/html`
|
|
92
|
-
* and `text/plain`. Gmail, Yahoo and Mail.ru read it; everyone else reads the
|
|
93
|
-
* html part and never knows this existed. That is the point: `./colors.ts`'s
|
|
94
|
-
* `hoverStyle` is absent in Gmail on the html surface, and here it simply works.
|
|
95
|
-
*
|
|
96
|
-
* Everything AMP forbids is absent BY CONSTRUCTION rather than by stripping:
|
|
97
|
-
*
|
|
98
|
-
* - **No inline `style`.** The lines are class-only; only `htmlShell` inlines them.
|
|
99
|
-
* - **No `!important`.** `hoverStyle(false)` — there is no inline style to beat
|
|
100
|
-
* here, so nothing needs to win a specificity fight, and AMP forbids the keyword
|
|
101
|
-
* anyway.
|
|
102
|
-
* - **No `<!--[if mso]>`.** `MSO_CTA_STYLE` lives in `htmlShell`'s `<head>`, never
|
|
103
|
-
* in a line, so nothing has to remove it. Outlook is not an AMP client, and
|
|
104
|
-
* `display: block` needs no correction in the three that are.
|
|
105
|
-
*
|
|
106
|
-
* **No dark scheme, and that is not an omission.** AMP4EMAIL disallows the
|
|
107
|
-
* `prefers-color-scheme` media feature — verified against the AMP validator, with
|
|
108
|
-
* and without `data-css-strict` — so `DARK_STYLE` cannot appear here in any form.
|
|
109
|
-
* It costs nothing: that block is read by Apple Mail, iOS Mail and Outlook for Mac,
|
|
110
|
-
* and this surface is read by Gmail, Yahoo and Mail.ru. The two sets are disjoint,
|
|
111
|
-
* and the AMP clients force-invert rather than honour a stylesheet — so they were
|
|
112
|
-
* never receiving our dark colours on the html surface either. What this surface
|
|
113
|
-
* adds is `:hover`, which those clients DO honour and which the html surface cannot
|
|
114
|
-
* reach them with. It trades nothing away.
|
|
115
|
-
*
|
|
116
|
-
* The boilerplate hides the body until the runtime unhides it — which is why an
|
|
117
|
-
* AMP document that cannot run scripts shows nothing at all, rather than showing
|
|
118
|
-
* an unstyled version of itself.
|
|
119
|
-
*/
|
|
120
|
-
export function ampShell(lines: EmailLine[]): string {
|
|
121
|
-
const inner = ampify(
|
|
122
|
-
lines
|
|
123
|
-
.map((line) => line.html)
|
|
124
|
-
.filter(Boolean)
|
|
125
|
-
.join("\n"),
|
|
126
|
-
);
|
|
127
|
-
// `amp4email` rather than the `⚡4email` the spec also allows: both are valid,
|
|
128
|
-
// and one of them survives every editor, terminal and diff it will ever cross.
|
|
129
|
-
//
|
|
130
|
-
// `data-css-strict` opts into AMP's strict CSS validation. The validator warns
|
|
131
|
-
// when it is absent and says it "may become an error in the future"; our CSS
|
|
132
|
-
// passes with and without it, so taking it now costs nothing and means that
|
|
133
|
-
// future arrives already handled.
|
|
134
|
-
//
|
|
135
|
-
// The shell wears its own recipes as CLASSES, where `htmlShell` inlines them —
|
|
136
|
-
// that asymmetry is the whole surface split, and it means `body` and `frame`
|
|
137
|
-
// need no special case: `AMP_RECIPE_RULES` already states every recipe's rule.
|
|
138
|
-
return `<!DOCTYPE html>
|
|
139
|
-
<html amp4email data-css-strict lang="en">
|
|
140
|
-
<head><meta charset="utf-8">
|
|
141
|
-
<script async src="https://cdn.ampproject.org/v0.js"></script>
|
|
142
|
-
<style amp4email-boilerplate>body{visibility:hidden}</style>
|
|
143
|
-
<style amp-custom>${AMP_RECIPE_RULES}
|
|
144
|
-
${hoverStyle(false)}</style></head>
|
|
145
|
-
<body ${styleClass("body")}>
|
|
146
|
-
<div ${styleClass("frame")}>
|
|
147
|
-
${inner}
|
|
148
|
-
</div>
|
|
149
|
-
</body>
|
|
150
|
-
</html>`;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
90
|
/**
|
|
154
91
|
* Join rendered lines into the plain-text email — separated per each line's
|
|
155
92
|
* spacing, with a trailing newline.
|
|
@@ -250,10 +250,16 @@ export const STYLE_NAMES = Object.keys(RECIPES) as StyleName[];
|
|
|
250
250
|
*
|
|
251
251
|
* This is the whole mechanism. A `cs-` role class is a HOOK: `darkStyle` needs it
|
|
252
252
|
* in the shipped markup, so it survives. A `csr-` recipe class is a BUNDLE OF
|
|
253
|
-
* DECLARATIONS: the html surface spends it and drops it
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
253
|
+
* DECLARATIONS: the html surface spends it and drops it. One prefix test separates
|
|
254
|
+
* them — for `inlineStyles` below, and for `__tests__/colors.test.ts`, which asserts
|
|
255
|
+
* every SHIPPED class has a dark rule and would otherwise trip over every recipe
|
|
256
|
+
* class in this file.
|
|
257
|
+
*
|
|
258
|
+
* The registry once had a second consumer — the AMP surface kept these classes and
|
|
259
|
+
* stated their rules, and that opposition (html inlines, AMP does not) is why it
|
|
260
|
+
* exists. AMP is gone (ADR-CONTRACTS-091) and the registry stays: it is now the
|
|
261
|
+
* single source for a recipe, spent in one place, and the markup it produces is
|
|
262
|
+
* byte-identical either way.
|
|
257
263
|
*/
|
|
258
264
|
const RECIPE_PREFIX = "csr-";
|
|
259
265
|
|
|
@@ -279,11 +285,6 @@ export function inlineOf(name: StyleName): string {
|
|
|
279
285
|
return `${RECIPES[name].join("; ")};`;
|
|
280
286
|
}
|
|
281
287
|
|
|
282
|
-
/** `.csr-bubble-user { border-radius: 8px; … }` — one recipe, as a rule. */
|
|
283
|
-
export function ruleOf(name: StyleName): string {
|
|
284
|
-
return `.${recipeClass(name)} { ${inlineOf(name)} }`;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
288
|
/**
|
|
288
289
|
* Class-only markup → the html surface's markup.
|
|
289
290
|
*
|
|
@@ -316,46 +317,3 @@ export function inlineStyles(html: string): string {
|
|
|
316
317
|
.join(" ");
|
|
317
318
|
});
|
|
318
319
|
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Class-only markup → the AMP surface's markup.
|
|
322
|
-
*
|
|
323
|
-
* The mirror of `inlineStyles`, and the reason both exist: that one resolves the
|
|
324
|
-
* surfaces' disagreement about STYLE, this one their disagreement about TAGS. AMP
|
|
325
|
-
* keeps the classes (its stylesheet is what needs them), so all this has to do is
|
|
326
|
-
* the one structural swap the surfaces cannot share.
|
|
327
|
-
*
|
|
328
|
-
* `<img>` → `<amp-img>`, which AMP requires and which needs explicit dimensions to
|
|
329
|
-
* reserve layout before the image loads. `../../content.ts` makes them optional,
|
|
330
|
-
* because a channel that can measure an image should not be told — so when they
|
|
331
|
-
* are absent this surface CANNOT draw the image, and says the `alt` text instead.
|
|
332
|
-
* That is a degradation, not a lie: `alt` is required exactly so a channel with
|
|
333
|
-
* nothing else has something to say, and the html surface still draws the picture.
|
|
334
|
-
*
|
|
335
|
-
* This does not contradict `supports("heroImage") === true` — `../../renderer.ts`
|
|
336
|
-
* puts `supports` on the RENDERER, and AMP is one of the email renderer's surfaces,
|
|
337
|
-
* not a renderer. Email can depict a hero image. One of its three MIME parts is
|
|
338
|
-
* plain text, which cannot, and that has never made the claim false.
|
|
339
|
-
*/
|
|
340
|
-
export function ampify(html: string): string {
|
|
341
|
-
return html.replace(/<img ([^>]*)>/g, (_match, attrs: string) => {
|
|
342
|
-
const sized = /\bwidth="\d+"/.test(attrs) && /\bheight="\d+"/.test(attrs);
|
|
343
|
-
if (sized) return `<amp-img ${attrs} layout="intrinsic"></amp-img>`;
|
|
344
|
-
const alt = /\balt="([^"]*)"/.exec(attrs)?.[1] ?? "";
|
|
345
|
-
// Carry the image's own spacing over to the paragraph replacing it, so the
|
|
346
|
-
// gap below it does not change with the fallback.
|
|
347
|
-
const spacing = /\bcsr-hero-(\w+)\b/.exec(attrs)?.[1] ?? "normal";
|
|
348
|
-
return `<p class="${RECIPE_PREFIX}p-${spacing}">${alt}</p>`;
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* Every recipe as a rule, for `<style amp-custom>`.
|
|
354
|
-
*
|
|
355
|
-
* ALL of them, always — not just the ones this email happens to use. Emitting the
|
|
356
|
-
* used subset would make the stylesheet a function of the content, so two emails
|
|
357
|
-
* would carry two different stylesheets and the snapshot would stop being a
|
|
358
|
-
* statement about the channel. ~40 rules is about 4KB against AMP's 75,000-byte
|
|
359
|
-
* budget, which `__tests__/amp.test.ts` holds us to.
|
|
360
|
-
*/
|
|
361
|
-
export const AMP_RECIPE_RULES = STYLE_NAMES.map(ruleOf).join("\n");
|