@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.
- package/package.json +1 -1
- package/src/components/app-shell-slotted.tsx +5 -1
- package/src/components/compose-body.language.test.ts +236 -0
- package/src/components/compose-body.stories.tsx +26 -0
- package/src/components/compose-form-shell.render.test.ts +59 -0
- package/src/components/compose-form-shell.tsx +36 -10
- package/src/components/email-frame-css.ts +110 -29
- package/src/components/isolated-email-frame.render.test.ts +174 -36
- package/src/components/isolated-email-frame.stories.tsx +518 -33
- package/src/components/isolated-email-frame.tsx +58 -135
- package/src/components/message-body-view.stories.tsx +200 -2
- package/src/components/message-body-view.tsx +82 -33
- package/src/components/mobile-reading-pane.tsx +2 -1
- package/src/components/reading-pane.stories.tsx +138 -3
- package/src/components/reading-pane.tsx +35 -26
- package/src/components/resizable.tsx +42 -0
- package/src/components/slide-panel.tsx +7 -3
- package/src/index.ts +8 -1
- package/src/lib/compose-language.test.ts +17 -0
- package/src/lib/compose-language.ts +17 -6
- package/src/lib/detect-compose-language.test.ts +18 -0
- package/src/lib/email-layout-clamp.test.ts +30 -0
- package/src/lib/email-layout-clamp.ts +17 -0
- package/src/lib/email-sanitizer.test.ts +153 -0
- package/src/lib/email-sanitizer.ts +165 -0
- package/src/lib/keymap.test.ts +16 -0
- package/src/lib/keymap.ts +16 -4
|
@@ -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 {
|
|
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
|
-
|
|
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,
|
|
15
|
-
assert.equal(measureContentAxis(900, 600,
|
|
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,
|
|
20
|
-
assert.equal(measureContentAxis(0, 899.4,
|
|
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,
|
|
30
|
-
assert.equal(measureContentAxis(0, 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("
|
|
35
|
-
it("
|
|
36
|
-
assert.
|
|
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("
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
});
|