@remit/ui 0.0.122 → 0.0.123

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.
@@ -1,23 +1,85 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
3
5
  import {
4
6
  buildEmailSrcDoc,
5
7
  DARK_OPT_IN_RE,
8
+ generateContentInsetCSS,
6
9
  generateFramedEmailBaseCSS,
7
10
  generatePlainEmailBaseCSS,
11
+ generateScrollportCSS,
8
12
  VIEWPORT_META,
9
13
  } from "./email-frame-css.js";
10
- import { computeFitScale, measureContentAxis } from "./isolated-email-frame.js";
14
+ import {
15
+ IsolatedEmailFrame,
16
+ measureContentAxis,
17
+ } from "./isolated-email-frame.js";
18
+
19
+ const SHORT_MAIL = "<p>Thursday works. See you at one.</p>";
20
+
21
+ // The shape the frame used to measure itself against: a table wider than any
22
+ // reading column, with a `min-width` the clamp cannot collapse.
23
+ const WIDE_MAIL =
24
+ '<table width="2400"><tr><td width="2400" style="min-width:2400px">' +
25
+ "<p>A ledger nobody can reflow.</p></td></tr></table>";
26
+
27
+ const renderFrame = (html: string): string =>
28
+ renderToString(
29
+ createElement(IsolatedEmailFrame, {
30
+ html,
31
+ variant: "framed",
32
+ isDark: false,
33
+ declares: { background: true, spacing: true },
34
+ }),
35
+ );
36
+
37
+ /**
38
+ * The style the iframe ELEMENT carries. The email's own markup rides along in
39
+ * `srcdoc`, quotes and all, so it goes before anything reads an attribute off
40
+ * the element.
41
+ */
42
+ const frameStyle = (html: string): string => {
43
+ const element = renderFrame(html).replace(/srcdoc="[^"]*"/, "");
44
+ return /style="([^"]*)"/.exec(element)?.[1] ?? "";
45
+ };
46
+
47
+ describe("the frame's box is the app's layout, never the mail's", () => {
48
+ it("takes the width of the box holding it", () => {
49
+ assert.match(frameStyle(SHORT_MAIL), /width:100%/);
50
+ });
51
+
52
+ it("gives mail that cannot fit the same box as mail that can", () => {
53
+ // The whole width policy in one assertion: nothing about the email
54
+ // reaches the element the app has laid out, so no mail can move a box
55
+ // the reader can see.
56
+ assert.equal(frameStyle(WIDE_MAIL), frameStyle(SHORT_MAIL));
57
+ });
11
58
 
12
- describe("measureContentAxis (content-sizing)", () => {
59
+ it("never resolves a width off the mail's own markup", () => {
60
+ assert.doesNotMatch(frameStyle(WIDE_MAIL), /2400/);
61
+ });
62
+
63
+ it("holds no scrollport of its own", () => {
64
+ // A frame that scrolled would put the email's overflow in the app's
65
+ // chrome. The document inside it owns that, via the scrollport CSS.
66
+ assert.doesNotMatch(frameStyle(WIDE_MAIL), /overflow/);
67
+ assert.ok(
68
+ renderFrame(WIDE_MAIL).includes(generateScrollportCSS()),
69
+ "the document the frame carries is the one that scrolls",
70
+ );
71
+ });
72
+ });
73
+
74
+ describe("measureContentAxis (the frame's height, and only its height)", () => {
13
75
  it("takes the larger of body and documentElement scroll size", () => {
14
- assert.equal(measureContentAxis(600, 900, 10_000), 900);
15
- assert.equal(measureContentAxis(900, 600, 10_000), 900);
76
+ assert.equal(measureContentAxis(600, 900, 50_000), 900);
77
+ assert.equal(measureContentAxis(900, 600, 50_000), 900);
16
78
  });
17
79
 
18
80
  it("rounds UP so a fractional content size never leaves a 1px phantom overflow", () => {
19
- assert.equal(measureContentAxis(600.1, 0, 10_000), 601);
20
- assert.equal(measureContentAxis(0, 899.4, 10_000), 900);
81
+ assert.equal(measureContentAxis(600.1, 0, 50_000), 601);
82
+ assert.equal(measureContentAxis(0, 899.4, 50_000), 900);
21
83
  });
22
84
 
23
85
  it("caps at the supplied max so a hostile sender can't allocate unbounded layout", () => {
@@ -26,30 +88,21 @@ describe("measureContentAxis (content-sizing)", () => {
26
88
  });
27
89
 
28
90
  it("returns an exact integer for already-integral content (no spurious +1)", () => {
29
- assert.equal(measureContentAxis(672, 0, 10_000), 672);
30
- assert.equal(measureContentAxis(0, 0, 10_000), 0);
91
+ assert.equal(measureContentAxis(672, 0, 50_000), 672);
92
+ assert.equal(measureContentAxis(0, 0, 50_000), 0);
31
93
  });
32
94
  });
33
95
 
34
- describe("computeFitScale (mobile fit-to-width #727)", () => {
35
- it("does not scale content that already fits the container", () => {
36
- assert.equal(computeFitScale(364, 364), 1);
37
- assert.equal(computeFitScale(300, 364), 1);
38
- });
39
-
40
- it("downscales a fixed-width newsletter to the container width", () => {
41
- // 648px Node-Weekly table into a 364px phone container.
42
- assert.equal(computeFitScale(648, 364), 364 / 648);
96
+ describe("generateScrollportCSS (the document holds its own overflow)", () => {
97
+ it("makes the body the scrollport rather than the frame", () => {
98
+ assert.match(generateScrollportCSS(), /body\{overflow-x:auto\}/);
43
99
  });
44
100
 
45
- it("floors the scale so a pathologically wide email stays readable", () => {
46
- assert.equal(computeFitScale(4000, 364), 0.4);
47
- });
48
-
49
- it("never upscales and never divides by an unknown width", () => {
50
- assert.equal(computeFitScale(0, 364), 1);
51
- assert.equal(computeFitScale(648, 0), 1);
52
- assert.equal(computeFitScale(-10, 364), 1);
101
+ it("stops the body's overflow propagating to the frame's viewport", () => {
102
+ // Overflow on the body becomes the viewport's unless the root claims it
103
+ // first, and a scrolling viewport is the app holding the email's width
104
+ // again.
105
+ assert.match(generateScrollportCSS(), /html\{overflow:hidden\}/);
53
106
  });
54
107
  });
55
108
 
@@ -57,17 +110,30 @@ describe("generatePlainEmailBaseCSS (theme tokens pinned)", () => {
57
110
  it("injects the light-theme resolved tokens", () => {
58
111
  const css = generatePlainEmailBaseCSS(false);
59
112
  assert.match(css, /oklch\(0\.3 0\.025 235\)/);
60
- assert.match(css, /oklch\(0\.975 0\.012 90\)/);
61
113
  assert.match(css, /oklch\(0\.55 0\.14 150\)/);
62
114
  });
63
115
 
64
116
  it("injects the dark-theme resolved tokens", () => {
65
117
  const css = generatePlainEmailBaseCSS(true);
66
118
  assert.match(css, /oklch\(0\.88 0\.02 90\)/);
67
- assert.match(css, /oklch\(0\.25 0\.025 220\)/);
68
119
  assert.match(css, /oklch\(0\.78 0\.16 150\)/);
69
120
  });
70
121
 
122
+ it("grounds the email on the reading pane's own canvas, not a lighter surface", () => {
123
+ // --surface (0.975 / 0.25) would render the mail as a lighter rectangle
124
+ // inside the pane, seam and all. --canvas is the pane itself.
125
+ assert.match(generatePlainEmailBaseCSS(false), /oklch\(0\.96 0\.015 90\)/);
126
+ assert.doesNotMatch(
127
+ generatePlainEmailBaseCSS(false),
128
+ /oklch\(0\.975 0\.012 90\)/,
129
+ );
130
+ assert.match(generatePlainEmailBaseCSS(true), /oklch\(0\.22 0\.025 220\)/);
131
+ assert.doesNotMatch(
132
+ generatePlainEmailBaseCSS(true),
133
+ /oklch\(0\.25 0\.025 220\)/,
134
+ );
135
+ });
136
+
71
137
  it("strips author colors and backgrounds from body descendants only", () => {
72
138
  const css = generatePlainEmailBaseCSS(false);
73
139
  assert.match(css, /body \*\s*\{[^}]*color:\s*inherit\s*!important/);
@@ -79,27 +145,65 @@ describe("generatePlainEmailBaseCSS (theme tokens pinned)", () => {
79
145
  });
80
146
 
81
147
  describe("generateFramedEmailBaseCSS (K-9 dark strategy)", () => {
82
- it("light theme renders as authored on a white canvas, no invert", () => {
83
- const css = generateFramedEmailBaseCSS(false, false);
148
+ it("light theme renders an author background as authored on white, no invert", () => {
149
+ const css = generateFramedEmailBaseCSS(false, false, true);
84
150
  assert.match(css, /background-color:#ffffff/);
85
151
  assert.match(css, /color-scheme:light/);
86
152
  assert.doesNotMatch(css, /invert/);
87
153
  });
88
154
 
89
- it("dark theme without opt-in smart-inverts to darken into the pane", () => {
90
- const css = generateFramedEmailBaseCSS(true, false);
91
- assert.match(css, /filter:invert\(0\.92\) hue-rotate\(180deg\)/);
155
+ it("dark theme without opt-in smart-inverts an author background into the pane", () => {
156
+ const css = generateFramedEmailBaseCSS(true, false, true);
157
+ assert.match(css, /html\{[^}]*filter:invert\(0\.92\) hue-rotate\(180deg\)/);
92
158
  // Media re-inverted back to natural color.
93
159
  assert.match(css, /img,picture,video[^{]*\{filter:invert/);
94
160
  });
95
161
 
96
162
  it("dark theme WITH opt-in preserves the author's own dark design (no invert)", () => {
97
- const css = generateFramedEmailBaseCSS(true, true);
163
+ const css = generateFramedEmailBaseCSS(true, true, true);
98
164
  assert.match(css, /color-scheme:dark light/);
99
165
  assert.doesNotMatch(css, /invert/);
100
166
  });
101
167
  });
102
168
 
169
+ describe("generateFramedEmailBaseCSS (mail that brings no ground of its own)", () => {
170
+ it("grounds a light-theme email on the pane's canvas instead of white", () => {
171
+ const css = generateFramedEmailBaseCSS(false, false, false);
172
+ assert.match(css, /background-color:oklch\(0\.96 0\.015 90\)/);
173
+ assert.doesNotMatch(css, /#ffffff/);
174
+ });
175
+
176
+ it("grounds a dark-theme email on the pane's canvas and never inverts it", () => {
177
+ // The invert moves off `html` onto `body`: the author's colours still
178
+ // darken, but a white canvas is never painted for it to turn into a
179
+ // charcoal slab sitting inside the pane.
180
+ const css = generateFramedEmailBaseCSS(true, false, false);
181
+ assert.match(
182
+ css,
183
+ /html\{margin:0;background-color:oklch\(0\.22 0\.025 220\)\}/,
184
+ );
185
+ assert.match(css, /body\{margin:0;filter:invert\(0\.92\)/);
186
+ assert.doesNotMatch(css, /#ffffff/);
187
+ });
188
+
189
+ it("grounds a dark opt-in email on the pane's canvas", () => {
190
+ const css = generateFramedEmailBaseCSS(true, true, false);
191
+ assert.match(css, /background-color:oklch\(0\.22 0\.025 220\)/);
192
+ assert.doesNotMatch(css, /invert/);
193
+ });
194
+ });
195
+
196
+ describe("generateContentInsetCSS (breathing room inside the background)", () => {
197
+ it("pads the element that carries the background, in its border box", () => {
198
+ const css = generateContentInsetCSS();
199
+ assert.match(css, /body\{padding:16px;box-sizing:border-box\}/);
200
+ });
201
+
202
+ it("leaves the document itself unpadded so the ground reaches the frame edge", () => {
203
+ assert.match(generateContentInsetCSS(), /html\{padding:0\}/);
204
+ });
205
+ });
206
+
103
207
  describe("DARK_OPT_IN_RE", () => {
104
208
  it("detects color-scheme: dark and prefers-color-scheme: dark", () => {
105
209
  assert.ok(DARK_OPT_IN_RE.test(":root{color-scheme: dark}"));
@@ -121,8 +225,21 @@ describe("buildEmailSrcDoc", () => {
121
225
 
122
226
  it("keeps the sanitized email body intact after the injected style", () => {
123
227
  const body = '<style>.clamp{}</style><table width="600"></table>';
124
- const doc = buildEmailSrcDoc(body, "framed", false);
125
- assert.ok(doc.endsWith(body));
228
+ const doc = buildEmailSrcDoc(body, "framed", false, {
229
+ background: true,
230
+ spacing: true,
231
+ });
232
+ assert.ok(doc.includes(body));
233
+ });
234
+
235
+ it("gives every email a scrollport of its own, whatever it declares", () => {
236
+ for (const spacing of [true, false]) {
237
+ const doc = buildEmailSrcDoc("<p>x</p>", "framed", false, {
238
+ background: true,
239
+ spacing,
240
+ });
241
+ assert.ok(doc.includes(generateScrollportCSS()));
242
+ }
126
243
  });
127
244
 
128
245
  it("uses the plain base CSS for the plain variant", () => {
@@ -131,7 +248,10 @@ describe("buildEmailSrcDoc", () => {
131
248
  });
132
249
 
133
250
  it("uses the framed smart-invert in dark mode for a non-opt-in framed email", () => {
134
- const doc = buildEmailSrcDoc("<p>x</p>", "framed", true);
251
+ const doc = buildEmailSrcDoc("<p>x</p>", "framed", true, {
252
+ background: true,
253
+ spacing: true,
254
+ });
135
255
  assert.match(doc, /filter:invert\(0\.92\)/);
136
256
  });
137
257
 
@@ -140,8 +260,26 @@ describe("buildEmailSrcDoc", () => {
140
260
  "<style>:root{color-scheme:dark}</style><p>x</p>",
141
261
  "framed",
142
262
  true,
263
+ { background: true, spacing: true },
143
264
  );
144
265
  assert.match(doc, /color-scheme:dark light/);
145
266
  assert.doesNotMatch(doc, /filter:invert/);
146
267
  });
268
+
269
+ it("insets mail that lays out no spacing of its own, after everything else", () => {
270
+ const doc = buildEmailSrcDoc("<p>x</p>", "plain", false, {
271
+ background: false,
272
+ spacing: false,
273
+ });
274
+ // Last in the document, so it outranks the layout clamp's `padding: 0`.
275
+ assert.ok(doc.endsWith(`${generateContentInsetCSS()}</style>`));
276
+ });
277
+
278
+ it("gives a newsletter that spaces its own container no second helping", () => {
279
+ const doc = buildEmailSrcDoc("<p>x</p>", "framed", false, {
280
+ background: true,
281
+ spacing: true,
282
+ });
283
+ assert.doesNotMatch(doc, /box-sizing:border-box/);
284
+ });
147
285
  });