@remit/ui 0.0.121 → 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.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/src/components/app-shell-slotted.tsx +5 -1
  3. package/src/components/compose-body.language.test.ts +236 -0
  4. package/src/components/compose-body.stories.tsx +26 -0
  5. package/src/components/compose-form-shell.render.test.ts +59 -0
  6. package/src/components/compose-form-shell.tsx +36 -10
  7. package/src/components/email-frame-css.ts +110 -29
  8. package/src/components/isolated-email-frame.render.test.ts +174 -36
  9. package/src/components/isolated-email-frame.stories.tsx +518 -33
  10. package/src/components/isolated-email-frame.tsx +58 -135
  11. package/src/components/message-body-view.stories.tsx +200 -2
  12. package/src/components/message-body-view.tsx +82 -33
  13. package/src/components/mobile-reading-pane.tsx +2 -1
  14. package/src/components/reading-pane.stories.tsx +138 -3
  15. package/src/components/reading-pane.tsx +35 -26
  16. package/src/components/resizable.tsx +42 -0
  17. package/src/components/selection-wizard.render.test.ts +42 -1
  18. package/src/components/selection-wizard.tsx +50 -29
  19. package/src/components/slide-panel.tsx +7 -3
  20. package/src/index.ts +8 -1
  21. package/src/lib/compose-language.test.ts +17 -0
  22. package/src/lib/compose-language.ts +17 -6
  23. package/src/lib/detect-compose-language.test.ts +18 -0
  24. package/src/lib/email-layout-clamp.test.ts +30 -0
  25. package/src/lib/email-layout-clamp.ts +17 -0
  26. package/src/lib/email-sanitizer.test.ts +153 -0
  27. package/src/lib/email-sanitizer.ts +165 -0
  28. package/src/lib/keymap.test.ts +16 -0
  29. package/src/lib/keymap.ts +16 -4
  30. package/src/lib/wizard-steps.test.ts +11 -0
  31. package/src/lib/wizard-steps.ts +21 -0
@@ -54,7 +54,7 @@ export function SlidePanel({
54
54
 
55
55
  <div
56
56
  className={cn(
57
- "safe-area-frame fixed top-0 right-0 z-50 h-full w-full border-l border-line bg-canvas shadow-xl sm:w-[400px] sm:max-w-[90vw]",
57
+ "safe-area-frame fixed top-0 right-0 z-50 flex h-full w-full flex-col border-l border-line bg-canvas shadow-xl sm:w-[400px] sm:max-w-[90vw]",
58
58
  "transform transition-transform duration-200 ease-out",
59
59
  isOpen ? "translate-x-0" : "pointer-events-none translate-x-full",
60
60
  )}
@@ -64,7 +64,7 @@ export function SlidePanel({
64
64
  inert={!isOpen}
65
65
  aria-labelledby="slide-panel-title"
66
66
  >
67
- <div className="flex h-14 items-center justify-between border-b border-line px-4">
67
+ <div className="flex h-14 shrink-0 items-center justify-between border-b border-line px-4">
68
68
  <h2 id="slide-panel-title" className="font-semibold">
69
69
  {title}
70
70
  </h2>
@@ -78,7 +78,11 @@ export function SlidePanel({
78
78
  </button>
79
79
  </div>
80
80
 
81
- <div className="flex h-[calc(100%-3.5rem)] flex-col">
81
+ {/* The body takes what the header leaves, from the layout rather than
82
+ from arithmetic over the header's height: a computed height puts
83
+ the box on a fractional pixel and drifts the moment the header
84
+ does. */}
85
+ <div className="flex min-h-0 flex-1 flex-col">
82
86
  <div className="flex-1 overflow-auto p-4">{children}</div>
83
87
  {footer && (
84
88
  <div className="flex justify-end gap-3 border-t border-line bg-canvas p-4">
package/src/index.ts CHANGED
@@ -179,6 +179,7 @@ export {
179
179
  ComposeFormShell,
180
180
  type ComposeFormShellProps,
181
181
  type ComposeMode,
182
+ type ComposeShellLayout,
182
183
  composeModeLabels,
183
184
  } from "./components/compose-form-shell.js";
184
185
  export {
@@ -218,7 +219,10 @@ export {
218
219
  DialogBackdrop,
219
220
  type DialogBackdropProps,
220
221
  } from "./components/dialog-backdrop.js";
221
- export type { EmailFrameVariant } from "./components/email-frame-css.js";
222
+ export type {
223
+ AuthorDeclarations,
224
+ EmailFrameVariant,
225
+ } from "./components/email-frame-css.js";
222
226
  export {
223
227
  EventDetail,
224
228
  type EventDetailProps,
@@ -408,6 +412,7 @@ export {
408
412
  } from "./components/mail-header.js";
409
413
  export {
410
414
  type EmailRenderCategory,
415
+ MessageBodyRegion,
411
416
  MessageBodyView,
412
417
  type MessageBodyViewProps,
413
418
  } from "./components/message-body-view.js";
@@ -569,6 +574,7 @@ export {
569
574
  ResizableHandle,
570
575
  ResizablePanel,
571
576
  ResizablePanelGroup,
577
+ WholePixelWidth,
572
578
  } from "./components/resizable.js";
573
579
  export {
574
580
  EMPTY_RICH_TEXT,
@@ -819,6 +825,7 @@ export {
819
825
  export {
820
826
  createEmailSanitizer,
821
827
  detectAuthorBackground,
828
+ detectAuthorSpacing,
822
829
  type SanitizedEmail,
823
830
  type SanitizeOptions,
824
831
  sanitizeInlineStyle,
@@ -13,6 +13,9 @@ import {
13
13
  wrapWithLanguage,
14
14
  } from "./compose-language.js";
15
15
 
16
+ /** The dictionaries the published image stages, per `REMIT_SPELLCHECK_LANGUAGES`. */
17
+ const BUILT = ["en", "en-GB", "nl"];
18
+
16
19
  before(() => {
17
20
  const dom = new JSDOM("");
18
21
  globalThis.DOMParser = dom.window.DOMParser;
@@ -66,6 +69,20 @@ describe("defaultComposeLanguages", () => {
66
69
  it("falls back to English when the browser offers nothing usable", () => {
67
70
  assert.deepEqual(defaultComposeLanguages([]), ["en"]);
68
71
  });
72
+
73
+ it("offers what the build can spellcheck, not the browser alone", () => {
74
+ assert.deepEqual(defaultComposeLanguages(["en-US", "en"], BUILT), [
75
+ "en",
76
+ "nl",
77
+ ]);
78
+ });
79
+
80
+ it("keeps the browser's own language first, so it stays the default", () => {
81
+ assert.deepEqual(defaultComposeLanguages(["nl-NL", "nl"], BUILT), [
82
+ "nl",
83
+ "en",
84
+ ]);
85
+ });
69
86
  });
70
87
 
71
88
  describe("browserSpellcheckHelp", () => {
@@ -10,6 +10,8 @@
10
10
  * message the recipient's client can read the language off. See issue #686.
11
11
  */
12
12
 
13
+ import { spellcheckLanguages } from "../components/rich-text-spellcheck-languages.js";
14
+
13
15
  /**
14
16
  * A language the composer offers, and the ISO 639-3 code `franc-min` knows it
15
17
  * by. The two are separate alphabets: the tag is what goes on the document and
@@ -111,16 +113,25 @@ export const languageLabel = (tag: string): string => {
111
113
  };
112
114
 
113
115
  /**
114
- * The languages an account writes in when it has not said. The browser already
115
- * holds an ordered answer; `en` follows it, so a browser set to one language
116
- * nothing can detect still leaves the chip something to show.
116
+ * The languages an account writes in when it has not said: the browser's own
117
+ * ordered answer first, then the dictionaries this build carries, then `en`.
118
+ *
119
+ * The dictionaries are in there because the browser routinely names one
120
+ * language and the writer uses another — Dutch mail written on an English
121
+ * browser is the ordinary case, not the exception. A candidate set of one turns
122
+ * detection off, since there is nothing to choose between, and every message
123
+ * then keeps the default tag and is checked against the wrong dictionary. What
124
+ * the deployment staged is the other statement about which languages are
125
+ * written here, and it is a short list, which is what keeps detection accurate.
117
126
  */
118
127
  export const defaultComposeLanguages = (
119
128
  locales: readonly string[],
129
+ built: readonly string[] = spellcheckLanguages(),
120
130
  ): string[] => {
121
- const known = locales.filter((locale) => detectionCodeFor(locale) !== null);
122
- const tags = known.map(primaryLanguageSubtag);
123
- const unique = [...new Set(tags)];
131
+ const known = [...locales, ...built].filter(
132
+ (locale) => detectionCodeFor(locale) !== null,
133
+ );
134
+ const unique = [...new Set(known.map(primaryLanguageSubtag))];
124
135
  if (!unique.includes("en")) unique.push("en");
125
136
  return unique;
126
137
  };
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import assert from "node:assert/strict";
7
7
  import { describe, it } from "node:test";
8
+ import { defaultComposeLanguages } from "./compose-language.js";
8
9
  import { detectComposeLanguage } from "./detect-compose-language.js";
9
10
 
10
11
  const DUTCH =
@@ -45,6 +46,23 @@ describe("detectComposeLanguage", () => {
45
46
  assert.equal(detectComposeLanguage(DUTCH, ["nl", "ja", "en"]), "nl");
46
47
  });
47
48
 
49
+ it("reads a Dutch line written on an English browser", () => {
50
+ // The account has never been to the language setting, and the browser it is
51
+ // read on says English and nothing else. A candidate set built from that
52
+ // alone has nothing to choose between, and every Dutch message goes out
53
+ // tagged `en` with the English dictionary underlining all of it.
54
+ const candidates = defaultComposeLanguages(
55
+ ["en-US", "en"],
56
+ ["en", "en-GB", "nl"],
57
+ );
58
+ assert.equal(
59
+ detectComposeLanguage("OK nou dank je wel hoor flapsigaar", candidates),
60
+ "nl",
61
+ );
62
+ assert.equal(detectComposeLanguage(DUTCH, candidates), "nl");
63
+ assert.equal(detectComposeLanguage(ENGLISH, candidates), "en");
64
+ });
65
+
48
66
  it("resolves a regional tag through its language", () => {
49
67
  assert.equal(detectComposeLanguage(ENGLISH, ["nl", "en-GB"]), "en-GB");
50
68
  });
@@ -89,6 +89,36 @@ describe("generateLayoutClampCSS (#374 / #727)", () => {
89
89
  );
90
90
  });
91
91
 
92
+ test("overrides an author nowrap so flowing text can never be clipped", () => {
93
+ // The frame is the pane's width, so a pinned paragraph is read by dragging
94
+ // the whole email sideways; inside an author `overflow:hidden` it is cut
95
+ // mid-character with nothing to scroll at all.
96
+ assert.ok(
97
+ /\[nowrap\][^{]*\{[^}]*white-space:\s*normal\s*!important/.test(css),
98
+ "a nowrap attribute must not pin a line wider than the frame",
99
+ );
100
+ assert.ok(
101
+ /\[style\*="nowrap" i\][^{]*\{[^}]*white-space:\s*normal\s*!important/.test(
102
+ css,
103
+ ),
104
+ "an inline white-space:nowrap must not pin a line wider than the frame",
105
+ );
106
+ assert.ok(
107
+ /\[style\*="nowrap" i\]/.test(css),
108
+ "Outlook emits WHITE-SPACE: NOWRAP — the attribute match must ignore case",
109
+ );
110
+ assert.ok(
111
+ /\[nowrap\]:not\(pre, code, pre \*, code \*\)/.test(css),
112
+ "pre/code AND their descendants keep their own whitespace handling",
113
+ );
114
+ assert.ok(
115
+ /:is\(pre, code, pre \*, code \*\):is\(\[nowrap\], \[style\*="nowrap" i\]\)[^{]*\{[^}]*white-space:\s*pre-wrap\s*!important/.test(
116
+ css,
117
+ ),
118
+ "a nowrap inside a pre wraps rather than collapsing the block's spacing",
119
+ );
120
+ });
121
+
92
122
  test("wraps long unbroken lines in pre/code blocks", () => {
93
123
  assert.ok(/\bpre\b/.test(css));
94
124
  assert.ok(/\bcode\b/.test(css));
@@ -55,10 +55,27 @@ td, th {
55
55
  * {
56
56
  min-width: 0;
57
57
  }
58
+ /* An author \`nowrap\` assumes a viewport as wide as the line. The frame is the
59
+ pane's width whatever the mail is, so a pinned paragraph turns reading one
60
+ sentence into a sideways drag — and inside an author \`overflow:hidden\` it is
61
+ cut mid-character with nothing left to scroll at all. Flowing text wraps. The
62
+ \`i\` flag is load-bearing: Outlook and older generators emit \`WHITE-SPACE:
63
+ NOWRAP\`, and an attribute value match is case-sensitive without it. */
64
+ [nowrap]:not(pre, code, pre *, code *),
65
+ [style*="nowrap" i]:not(pre, code, pre *, code *) {
66
+ white-space: normal !important;
67
+ }
58
68
  /* Long unbroken strings (URLs, tokens) wrap instead of forcing a wide line. */
59
69
  pre, code {
60
70
  white-space: pre-wrap;
61
71
  overflow-wrap: anywhere;
62
72
  word-break: break-word;
63
73
  }
74
+ /* Preformatted text keeps its spacing even where a nowrap is declared — on the
75
+ block or on a span inside it. \`normal\` would collapse the runs of spaces that
76
+ are the entire content of a \`pre\`, and the un-important rule above cannot
77
+ defend an inline style, so this wraps to \`pre-wrap\` instead of unwrapping. */
78
+ :is(pre, code, pre *, code *):is([nowrap], [style*="nowrap" i]) {
79
+ white-space: pre-wrap !important;
80
+ }
64
81
  `;
@@ -3,6 +3,7 @@ import { describe, test } from "node:test";
3
3
  import { buildCidResolver } from "./cid-resolver.js";
4
4
  import {
5
5
  detectAuthorBackground,
6
+ detectAuthorSpacing,
6
7
  sanitizeInlineStyle,
7
8
  sanitizeStyleElementCss,
8
9
  } from "./email-sanitizer.js";
@@ -360,3 +361,155 @@ describe("detectAuthorBackground — <style> block over-match hardening (#483)",
360
361
  assert.equal(detectAuthorBackground(html), false);
361
362
  });
362
363
  });
364
+
365
+ describe("detectAuthorSpacing — does the mail lay out its own breathing room?", () => {
366
+ test("a bare message declares none", () => {
367
+ assert.equal(
368
+ detectAuthorSpacing("<div><p>Hi there,</p><p>See you at 3.</p></div>"),
369
+ false,
370
+ );
371
+ });
372
+
373
+ test("a reset is not breathing room", () => {
374
+ assert.equal(
375
+ detectAuthorSpacing(
376
+ '<table style="margin:0;padding:0"><tr></tr></table>',
377
+ ),
378
+ false,
379
+ );
380
+ assert.equal(
381
+ detectAuthorSpacing("<style>body{margin:0 auto;padding:0px}</style>"),
382
+ false,
383
+ );
384
+ });
385
+
386
+ test("a reset that shouts is still a reset", () => {
387
+ assert.equal(
388
+ detectAuthorSpacing('<div style="padding:0 !important">x</div>'),
389
+ false,
390
+ );
391
+ });
392
+
393
+ test("an inline padding on a container counts", () => {
394
+ assert.equal(
395
+ detectAuthorSpacing('<td style="padding:24px;color:#111">x</td>'),
396
+ true,
397
+ );
398
+ });
399
+
400
+ test("a horizontal margin in a <style> block counts", () => {
401
+ assert.equal(
402
+ detectAuthorSpacing("<style>.wrap{margin:0 24px}</style><p>x</p>"),
403
+ true,
404
+ );
405
+ });
406
+
407
+ test("the tables newsletters are still built from count via cellpadding", () => {
408
+ assert.equal(detectAuthorSpacing('<table cellpadding="8"></table>'), true);
409
+ assert.equal(detectAuthorSpacing('<table cellpadding="0"></table>'), false);
410
+ });
411
+
412
+ test("the word margin in prose is not a declaration", () => {
413
+ assert.equal(
414
+ detectAuthorSpacing("<p>The margin was thin this quarter.</p>"),
415
+ false,
416
+ );
417
+ });
418
+
419
+ test("a mail about CSS quotes a declaration without making one", () => {
420
+ assert.equal(
421
+ detectAuthorSpacing(
422
+ '<p>Set style="padding:9px" on the cell and it lines up.</p>',
423
+ ),
424
+ false,
425
+ );
426
+ });
427
+
428
+ test("a commented-out declaration is not a declaration", () => {
429
+ assert.equal(
430
+ detectAuthorSpacing("<style>.wrap{/* padding: 20px */}</style><p>x</p>"),
431
+ false,
432
+ );
433
+ assert.equal(
434
+ detectAuthorSpacing(
435
+ '<!--[if mso]><td style="padding:20px"><![endif]--><p>x</p>',
436
+ ),
437
+ false,
438
+ );
439
+ });
440
+
441
+ test("CSS hidden from prehistoric clients still counts", () => {
442
+ assert.equal(
443
+ detectAuthorSpacing(
444
+ "<style><!-- .wrap{padding:24px} --></style><p>x</p>",
445
+ ),
446
+ true,
447
+ );
448
+ });
449
+
450
+ test("a property that merely ends in padding is not padding", () => {
451
+ assert.equal(
452
+ detectAuthorSpacing('<div style="scroll-padding:40px">x</div>'),
453
+ false,
454
+ );
455
+ assert.equal(
456
+ detectAuthorSpacing("<style>td{mso-padding-alt:0cm 5.4pt}</style>"),
457
+ false,
458
+ );
459
+ });
460
+
461
+ test("a negative margin pulls content out, it does not space it", () => {
462
+ assert.equal(
463
+ detectAuthorSpacing('<div style="margin-left:-12px">x</div>'),
464
+ false,
465
+ );
466
+ });
467
+ });
468
+
469
+ /**
470
+ * The commonest mail in an inbox is a personal reply, and a personal reply
471
+ * carries a quote. Every client indents that quote and spaces its paragraphs,
472
+ * so counting either as "the mail lays out its own container" left exactly the
473
+ * mail the inset exists for flush against a phone's screen edge.
474
+ */
475
+ describe("detectAuthorSpacing — the mail the inset exists for", () => {
476
+ test("an Apple Mail reply with a quoted original declares none", () => {
477
+ const html = [
478
+ "<div>Ja hoor, tot donderdag.</div><br>",
479
+ '<blockquote type="cite" style="margin:0 0 0 40px;border:none;padding:0px">',
480
+ "<div>Kun je donderdag?</div>",
481
+ "</blockquote>",
482
+ ].join("");
483
+ assert.equal(detectAuthorSpacing(html), false);
484
+ });
485
+
486
+ test("a Gmail reply with a quoted original declares none", () => {
487
+ const html = [
488
+ '<div dir="ltr">Works for me.</div>',
489
+ '<div class="gmail_quote">',
490
+ '<blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;',
491
+ 'border-left:1px solid rgb(204,204,204);padding-left:1ex">',
492
+ "<div>Thursday still good?</div>",
493
+ "</blockquote></div>",
494
+ ].join("");
495
+ assert.equal(detectAuthorSpacing(html), false);
496
+ });
497
+
498
+ test("Outlook paragraph spacing is not a container", () => {
499
+ const html = [
500
+ "<style>p.MsoNormal{margin:0cm;margin-bottom:.0001pt}</style>",
501
+ "<p class=MsoNormal style='margin-bottom:12.0pt'>Beste Matthijs,</p>",
502
+ "<p class=MsoNormal style='margin-bottom:12.0pt'>Met vriendelijke groet,</p>",
503
+ ].join("");
504
+ assert.equal(detectAuthorSpacing(html), false);
505
+ });
506
+
507
+ test("a newsletter that lays out its own container still counts", () => {
508
+ const html = [
509
+ '<table width="600" style="margin:0 auto">',
510
+ '<tr><td style="width:600px;padding:24px;background:#83cd29">',
511
+ "<h1>Node Weekly</h1></td></tr></table>",
512
+ ].join("");
513
+ assert.equal(detectAuthorSpacing(html), true);
514
+ });
515
+ });
@@ -27,10 +27,18 @@ export interface SanitizeOptions {
27
27
  * so author colors survive (newsletter / designed mail). When false, the
28
28
  * subtree inherits the app theme so plain mail blends with dark chrome
29
29
  * instead of becoming a bright slab (#375).
30
+ *
31
+ * `hasAuthorSpacing` is the same question for layout: does the mail hold its own
32
+ * content off its container's left and right edges? A newsletter that already
33
+ * spaces its container must not be given a second helping; everything else —
34
+ * including a personal reply whose only declarations are a quote's indent and
35
+ * the gaps between paragraphs — gets breathing room injected inside its own
36
+ * background.
30
37
  */
31
38
  export interface SanitizedEmail {
32
39
  html: string;
33
40
  hasAuthorBackground: boolean;
41
+ hasAuthorSpacing: boolean;
34
42
  }
35
43
 
36
44
  /**
@@ -112,6 +120,161 @@ export const detectAuthorBackground = (html: string): boolean => {
112
120
  return false;
113
121
  };
114
122
 
123
+ /**
124
+ * CSS keywords that space nothing: a `margin: 0` reset is not the mail laying
125
+ * out its own breathing room, and neither is `margin: auto` centring a table.
126
+ */
127
+ const NO_SPACING_KEYWORDS = new Set([
128
+ "auto",
129
+ "none",
130
+ "initial",
131
+ "inherit",
132
+ "unset",
133
+ "revert",
134
+ "revert-layer",
135
+ ]);
136
+
137
+ /**
138
+ * `padding` / `margin` declarations, and only those. The lookbehind keeps the
139
+ * properties that merely end in the same word out of the scan — `scroll-padding`
140
+ * on a scroll container and Outlook's `mso-padding-alt` are not the mail
141
+ * spacing its container.
142
+ */
143
+ const SPACING_DECL_RE =
144
+ /(?<![-\w])(?:padding|margin)(?:-(top|right|bottom|left|block|inline)(?:-(?:start|end))?)?\s*:\s*([^;}"']+)/gi;
145
+
146
+ const VERTICAL_SIDES = new Set(["top", "bottom", "block"]);
147
+
148
+ /** CSS comments hold no declarations, in a `<style>` block or in a `style=`. */
149
+ const stripCssComments = (css: string): string =>
150
+ css.replace(/\/\*[\s\S]*?\*\//g, "");
151
+
152
+ /**
153
+ * Whether one length token pushes content off the container edge. Zero, a
154
+ * keyword and a negative all fail — a negative margin pulls content out rather
155
+ * than spacing it in. Anything unrecognised (`calc()`, `var()`) counts, on the
156
+ * conservative side of leaving a self-spacing mail alone.
157
+ */
158
+ const isSpacingLength = (token: string): boolean => {
159
+ const value = token.toLowerCase();
160
+ if (!value || NO_SPACING_KEYWORDS.has(value)) return false;
161
+ if (value.startsWith("-")) return false;
162
+ const numeric = /^\+?(\d*\.?\d+)[a-z%]*$/.exec(value);
163
+ if (numeric) return Number.parseFloat(numeric[1]) !== 0;
164
+ return true;
165
+ };
166
+
167
+ /** The left/right components of a `padding`/`margin` shorthand value. */
168
+ const horizontalComponents = (value: string): string[] => {
169
+ const parts = value.split(/\s+/).filter(Boolean);
170
+ if (parts.length < 2) return parts;
171
+ if (parts.length < 4) return [parts[1]];
172
+ return [parts[1], parts[3]];
173
+ };
174
+
175
+ /**
176
+ * Whether one declaration spaces the container horizontally. Only the
177
+ * horizontal axis answers the question the inset asks: a `margin-bottom` between
178
+ * two Outlook paragraphs is the mail spacing its own paragraphs, not laying out
179
+ * a container, and a mail with nothing but that still runs into a phone's screen
180
+ * edge without the inset.
181
+ */
182
+ const spacesHorizontally = (side: string | undefined, raw: string): boolean => {
183
+ if (side && VERTICAL_SIDES.has(side)) return false;
184
+ const value = raw.replace(/!\s*important/gi, "").trim();
185
+ if (!value) return false;
186
+ if (side) return value.split(/\s+/).some(isSpacingLength);
187
+ return horizontalComponents(value).some(isSpacingLength);
188
+ };
189
+
190
+ /** Whether CSS text lays out horizontal container spacing. */
191
+ const declaresContainerSpacing = (css: string): boolean => {
192
+ for (const match of stripCssComments(css).matchAll(SPACING_DECL_RE)) {
193
+ if (spacesHorizontally(match[1], match[2])) return true;
194
+ }
195
+ return false;
196
+ };
197
+
198
+ /**
199
+ * Elements whose spacing is a convention rather than a container. Every mail
200
+ * client on earth indents a quote with a left margin on the `<blockquote>`, and
201
+ * a reply carrying a quote is exactly the mail the injected inset exists for.
202
+ */
203
+ const SPACING_EXEMPT_TAGS = new Set(["blockquote"]);
204
+
205
+ /** HTML comments hold no markup — including the `<!--[if mso]>` conditionals
206
+ * Outlook mail is padded with, which no other client ever renders. */
207
+ const stripHtmlComments = (html: string): string =>
208
+ html.replace(/<!--[\s\S]*?-->/g, "");
209
+
210
+ /**
211
+ * Every inline `style="…"` / `style='…'` value in the raw markup, with the tag
212
+ * carrying it. Scanning tags rather than the whole string is what keeps a
213
+ * literal `style="padding:9px"` written out in the text of a mail ABOUT CSS
214
+ * from reading as layout.
215
+ */
216
+ const extractStyledTags = (html: string): { tag: string; style: string }[] => {
217
+ const found: { tag: string; style: string }[] = [];
218
+ const tags = /<([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
219
+ const styles = /\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
220
+ for (const [, tag, attrs] of html.matchAll(tags)) {
221
+ for (const match of attrs.matchAll(styles)) {
222
+ found.push({ tag: tag.toLowerCase(), style: match[1] ?? match[2] ?? "" });
223
+ }
224
+ }
225
+ return found;
226
+ };
227
+
228
+ /** `selector { … }` pairs; a nested at-rule yields its inner rules. */
229
+ const extractCssRules = (css: string): { selector: string; body: string }[] =>
230
+ [...stripCssComments(css).matchAll(/([^{}]*)\{([^{}]*)\}/g)].map((match) => ({
231
+ selector: match[1].trim(),
232
+ body: match[2],
233
+ }));
234
+
235
+ /** The element a selector actually styles — the tag of its last compound. */
236
+ const selectorSubjectTag = (selector: string): string => {
237
+ const last =
238
+ selector
239
+ .trim()
240
+ .split(/[\s>+~]+/)
241
+ .pop() ?? "";
242
+ return (/^[a-zA-Z][\w-]*/.exec(last)?.[0] ?? "").toLowerCase();
243
+ };
244
+
245
+ const stylesOnlyQuotes = (selector: string): boolean =>
246
+ selector
247
+ .split(",")
248
+ .every((one) => SPACING_EXEMPT_TAGS.has(selectorSubjectTag(one)));
249
+
250
+ /**
251
+ * Detect whether the mail lays out horizontal spacing of its own — in an inline
252
+ * style, in a `<style>` block, or through a non-zero `cellpadding` on the tables
253
+ * newsletters are still built from. Same shape and same limits as
254
+ * `detectAuthorBackground`: a declaration scan over the raw markup, deliberately
255
+ * conservative, because the cost of a false positive (no injected inset) is a
256
+ * message that runs into the screen edge, while a false negative doubles a
257
+ * newsletter's own padding.
258
+ */
259
+ export const detectAuthorSpacing = (html: string): boolean => {
260
+ // Style blocks come off the raw string: old mail wraps its CSS in an HTML
261
+ // comment to hide it from clients that never shipped `<style>`.
262
+ const blocks = extractStyleBlocks(html);
263
+ const markup = stripHtmlComments(html);
264
+ if (/\bcellpadding\s*=\s*["']?(?!0["'\s>])\d/i.test(markup)) return true;
265
+ for (const { tag, style } of extractStyledTags(markup)) {
266
+ if (SPACING_EXEMPT_TAGS.has(tag)) continue;
267
+ if (declaresContainerSpacing(style)) return true;
268
+ }
269
+ for (const block of blocks) {
270
+ for (const rule of extractCssRules(block)) {
271
+ if (stylesOnlyQuotes(rule.selector)) continue;
272
+ if (declaresContainerSpacing(rule.body)) return true;
273
+ }
274
+ }
275
+ return false;
276
+ };
277
+
115
278
  const FORBIDDEN_TAGS = [
116
279
  "script",
117
280
  "iframe",
@@ -282,6 +445,7 @@ export const createEmailSanitizer = (options: SanitizeOptions = {}) => {
282
445
  // #375), but running the detector on the raw input is simpler and
283
446
  // not sensitive to any future hook rewrites.
284
447
  const hasAuthorBackground = detectAuthorBackground(html);
448
+ const hasAuthorSpacing = detectAuthorSpacing(html);
285
449
 
286
450
  const sanitized = purify.sanitize(html, config);
287
451
 
@@ -293,6 +457,7 @@ export const createEmailSanitizer = (options: SanitizeOptions = {}) => {
293
457
  return {
294
458
  html: `<style>${layoutCss}</style>${sanitized}`,
295
459
  hasAuthorBackground,
460
+ hasAuthorSpacing,
296
461
  };
297
462
  };
298
463
  };
@@ -54,6 +54,22 @@ describe("keymap module", () => {
54
54
  assert.strictEqual(tooltipForAction(unbound), "");
55
55
  });
56
56
 
57
+ test("the navigation hints name a direction on screen, not a place in time", () => {
58
+ const navigation = KEY_HINT_GROUPS.find((g) => g.title === "Navigation");
59
+ assert.ok(navigation, "the Navigation group is declared");
60
+
61
+ const describes = (key: string): string | undefined =>
62
+ navigation.hints.find((hint) => hint.keys.join("") === key)?.description;
63
+
64
+ // Both surfaces the keys serve read downward, and a conversation reads
65
+ // newest first — so down is back in time there. A description saying
66
+ // "next message" is read as the newer one, which is the opposite.
67
+ assert.strictEqual(describes("j"), "Focus the message below");
68
+ assert.strictEqual(describes("↓"), "Focus the message below");
69
+ assert.strictEqual(describes("k"), "Focus the message above");
70
+ assert.strictEqual(describes("↑"), "Focus the message above");
71
+ });
72
+
57
73
  test("every hint's action is a non-empty key list", () => {
58
74
  for (const group of KEY_HINT_GROUPS) {
59
75
  for (const hint of group.hints) {
package/src/lib/keymap.ts CHANGED
@@ -104,17 +104,29 @@ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
104
104
  {
105
105
  title: "Navigation",
106
106
  hints: [
107
- { action: "focusNext", keys: ["j"], description: "Focus next message" },
107
+ // The keys move through what is on screen, so the description is a
108
+ // direction rather than a position in time. A conversation reads newest
109
+ // first, where the message below is the older one — "next message" read
110
+ // as the newer one there and named the opposite of what j does.
111
+ {
112
+ action: "focusNext",
113
+ keys: ["j"],
114
+ description: "Focus the message below",
115
+ },
108
116
  {
109
117
  action: "focusPrevious",
110
118
  keys: ["k"],
111
- description: "Focus previous message",
119
+ description: "Focus the message above",
120
+ },
121
+ {
122
+ action: "focusNext",
123
+ keys: ["↓"],
124
+ description: "Focus the message below",
112
125
  },
113
- { action: "focusNext", keys: ["↓"], description: "Focus next message" },
114
126
  {
115
127
  action: "focusPrevious",
116
128
  keys: ["↑"],
117
- description: "Focus previous message",
129
+ description: "Focus the message above",
118
130
  },
119
131
  { action: "focusFirst", keys: ["Home"], description: "Focus first" },
120
132
  { action: "focusLast", keys: ["End"], description: "Focus last" },