@company-semantics/contracts 37.0.0 → 38.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.
@@ -216,8 +216,10 @@ export function renderElement(
216
216
  ];
217
217
 
218
218
  case "heroImage": {
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.
219
+ // Stated as attributes, not just style: they are the image's intrinsic
220
+ // size, every client uses them to reserve space before it loads, and
221
+ // `./styles.ts`'s `ampify` reads them to decide whether the AMP surface can
222
+ // draw an `<amp-img>` at all.
221
223
  const dimensions =
222
224
  element.width !== undefined && element.height !== undefined
223
225
  ? ` width="${element.width}" height="${element.height}"`
@@ -6,9 +6,6 @@
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
- *
12
9
  * INVARIANTS:
13
10
  * - Pure functions of their input lines. No clock, no environment, no I/O.
14
11
  * - These strings are the email's actual markup, and
@@ -16,10 +13,16 @@
16
13
  * character-for-character.
17
14
  */
18
15
 
19
- import { BASE_STYLE, DARK_STYLE } from "./colors";
16
+ import { BASE_STYLE, DARK_STYLE, hoverStyle } from "./colors";
20
17
  import type { Spacing } from "./constants";
21
18
  import { MSO_CTA_STYLE } from "./cta";
22
- import { inlineOf, inlineStyles } from "./styles";
19
+ import {
20
+ AMP_RECIPE_RULES,
21
+ ampify,
22
+ inlineOf,
23
+ inlineStyles,
24
+ styleClass,
25
+ } from "./styles";
23
26
 
24
27
  /**
25
28
  * One rendered line — both presentations of one thing the notification says,
@@ -45,16 +48,18 @@ export interface EmailLine {
45
48
  * protecting our colours, apply its dark background, and leave the inline LIGHT
46
49
  * text colour sitting on top of it. The two ship together or not at all.
47
50
  *
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.
51
+ * Only Apple Mail, iOS Mail and Outlook for Mac read any of this. Clients that
52
+ * recolour instead (Outlook.com, Windows Outlook, and measured
53
+ * **Gmail iOS**) ignore both metas and rewrite the inline styles post-delivery.
54
+ * Nothing here can stop that, which is why the body still states an explicit
55
+ * background rather than trusting a default.
56
+ *
57
+ * Do not read that as a degradation to fix: Gmail iOS's inversion of THIS part is
58
+ * the only dark mode a Gmail iOS reader gets, and preserving it is why `ampShell`'s
59
+ * output is never sent (ADR-CONTRACTS-092). Gmail web, by contrast, recolours
60
+ * nothing at all — a light email stays light in a dark client, and no surface fixes
61
+ * that. Gmail Android is untested: unknown, not negative.
62
+ * `company-semantics-backend/docs/dark-mode-probe.md`.
58
63
  *
59
64
  * The `<!--[if mso]>` block is the mirror image: it is read ONLY by the Outlook
60
65
  * Word engine, and undoes the one layout that engine cannot render — a linked
@@ -87,6 +92,87 @@ ${inner}
87
92
  </html>`;
88
93
  }
89
94
 
95
+ /**
96
+ * Wrap rendered lines in the AMP4EMAIL shell (ADR-CONTRACTS-089, -091, -092).
97
+ *
98
+ * ⚠️ **THIS BODY IS NEVER SENT, AND MUST NOT BE.** It is rendered, previewed in the
99
+ * Ladle gallery, and dropped. Delivering it is a REGRESSION, not a feature — see
100
+ * below. `company-semantics-backend`'s `EmailDelivery.test.ts` fails if it ever
101
+ * reaches the transport, and that test is the invariant; this comment is only its
102
+ * explanation.
103
+ *
104
+ * **Why it is not sent.** Gmail iOS inverts the `text/html` part post-delivery and
105
+ * does **NOT** touch a `text/x-amp-html` part. That inversion is the ONLY dark mode
106
+ * a Gmail iOS reader gets — no Gmail client honours `prefers-color-scheme` on any
107
+ * surface — so shipping an AMP part would switch it off and hand them a white email
108
+ * in a dark client. This surface buys `:hover` in Gmail and would cost dark mode in
109
+ * Gmail. That trade was measured and refused (ADR-CONTRACTS-091), and the measurement
110
+ * is `company-semantics-backend/docs/dark-mode-probe.md`. ADR-CONTRACTS-089 asserted
111
+ * the opposite — "AMP clients force-invert, so they never got our dark colours
112
+ * anyway" — and that is FALSE: Gmail web recolours nothing, Yahoo does not invert,
113
+ * and Gmail iOS exempts AMP.
114
+ *
115
+ * **Why it exists at all, then.** AMP is the only email format with a real animation
116
+ * model — `amp-animation`, `amp-bind`, `amp-position-observer` — and no arbitrary JS
117
+ * needed. Nothing else can make the kaomoji companions blink once, three seconds
118
+ * after open. That is a future worth keeping the surface alive for, and rebuilding it
119
+ * later costs more than carrying it inert now (ADR-CONTRACTS-092). If Google ever
120
+ * allows `prefers-color-scheme` here, or Gmail starts inverting AMP too, the trade
121
+ * flips and this is ready.
122
+ *
123
+ * **Light-only, permanently.** AMP4EMAIL disallows the `prefers-color-scheme` media
124
+ * feature — a hard validator error, checked with and without `data-css-strict` — so
125
+ * `DARK_STYLE` cannot appear here in any form. The `media` ATTRIBUTE on an AMP
126
+ * element takes the same feature and PASSES the validator; Gmail's runtime ignores
127
+ * it. Both doors are closed. That is not a gap to close; it is the format.
128
+ *
129
+ * Everything AMP forbids is absent BY CONSTRUCTION rather than by stripping:
130
+ *
131
+ * - **No inline `style`.** The lines are class-only; only `htmlShell` inlines them.
132
+ * - **No `!important`.** `hoverStyle(false)` — there is no inline style to beat
133
+ * here, so nothing needs to win a specificity fight, and AMP forbids the keyword
134
+ * anyway.
135
+ * - **No `<!--[if mso]>`.** `MSO_CTA_STYLE` lives in `htmlShell`'s `<head>`, never
136
+ * in a line, so nothing has to remove it. Outlook is not an AMP client, and
137
+ * `display: block` needs no correction in the three that are.
138
+ *
139
+ * The boilerplate hides the body until the runtime unhides it — which is why an
140
+ * AMP document that cannot run scripts shows nothing at all, rather than showing
141
+ * an unstyled version of itself.
142
+ */
143
+ export function ampShell(lines: EmailLine[]): string {
144
+ const inner = ampify(
145
+ lines
146
+ .map((line) => line.html)
147
+ .filter(Boolean)
148
+ .join("\n"),
149
+ );
150
+ // `amp4email` rather than the `⚡4email` the spec also allows: both are valid,
151
+ // and one of them survives every editor, terminal and diff it will ever cross.
152
+ //
153
+ // `data-css-strict` opts into AMP's strict CSS validation. The validator warns
154
+ // when it is absent and says it "may become an error in the future"; our CSS
155
+ // passes with and without it, so taking it now costs nothing and means that
156
+ // future arrives already handled.
157
+ //
158
+ // The shell wears its own recipes as CLASSES, where `htmlShell` inlines them —
159
+ // that asymmetry is the whole surface split, and it means `body` and `frame`
160
+ // need no special case: `AMP_RECIPE_RULES` already states every recipe's rule.
161
+ return `<!DOCTYPE html>
162
+ <html amp4email data-css-strict lang="en">
163
+ <head><meta charset="utf-8">
164
+ <script async src="https://cdn.ampproject.org/v0.js"></script>
165
+ <style amp4email-boilerplate>body{visibility:hidden}</style>
166
+ <style amp-custom>${AMP_RECIPE_RULES}
167
+ ${hoverStyle(false)}</style></head>
168
+ <body ${styleClass("body")}>
169
+ <div ${styleClass("frame")}>
170
+ ${inner}
171
+ </div>
172
+ </body>
173
+ </html>`;
174
+ }
175
+
90
176
  /**
91
177
  * Join rendered lines into the plain-text email — separated per each line's
92
178
  * spacing, with a trailing newline.
@@ -250,16 +250,10 @@ 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. 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.
253
+ * DECLARATIONS: the html surface spends it and drops it, the AMP surface keeps it
254
+ * and states its rule. One prefix test separates them — for `inlineStyles` below,
255
+ * and for `__tests__/colors.test.ts`, which asserts every SHIPPED class has a dark
256
+ * rule and would otherwise trip over every recipe class in this file.
263
257
  */
264
258
  const RECIPE_PREFIX = "csr-";
265
259
 
@@ -285,6 +279,11 @@ export function inlineOf(name: StyleName): string {
285
279
  return `${RECIPES[name].join("; ")};`;
286
280
  }
287
281
 
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
+
288
287
  /**
289
288
  * Class-only markup → the html surface's markup.
290
289
  *
@@ -317,3 +316,46 @@ export function inlineStyles(html: string): string {
317
316
  .join(" ");
318
317
  });
319
318
  }
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");