@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.122",
3
+ "version": "0.0.123",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -9,6 +9,7 @@ import {
9
9
  ResizableHandle,
10
10
  ResizablePanel,
11
11
  ResizablePanelGroup,
12
+ WholePixelWidth,
12
13
  } from "./resizable.js";
13
14
 
14
15
  /* ------------------------------------------------------------------ */
@@ -250,7 +251,10 @@ export function AppShellSlotted({
250
251
  minSize={29}
251
252
  className="min-w-0"
252
253
  >
253
- {reading}
254
+ {/* The email inside this pane is laid out against the pane's own
255
+ width, so the pane is the last place a fractional box is
256
+ allowed. */}
257
+ <WholePixelWidth className="h-full">{reading}</WholePixelWidth>
254
258
  </ResizablePanel>
255
259
  </>
256
260
  )}
@@ -0,0 +1,236 @@
1
+ /**
2
+ * The language of a message a Dutch writer types on an English browser, all the
3
+ * way through the surface: the chip, the tag on the writing surface, and the
4
+ * dictionary the checker is opened for.
5
+ *
6
+ * The account that has never been to the language setting is the ordinary case,
7
+ * and what it falls back on has to be a set detection can choose inside — a set
8
+ * of one is detection switched off, and the message then goes out tagged `en`
9
+ * with every Dutch word underlined.
10
+ *
11
+ * React is imported after the jsdom globals are installed so its DOM bindings
12
+ * bind to jsdom's prototypes.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
17
+ import type { JSDOM } from "jsdom";
18
+ import type {
19
+ act as reactAct,
20
+ createElement as reactCreateElement,
21
+ } from "react";
22
+ import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
23
+ import { defaultComposeLanguages } from "../lib/compose-language.js";
24
+ import type { ComposeBody as ComposeBodyType } from "./compose-body.js";
25
+ import type {
26
+ SpellcheckOptions,
27
+ SpellProvider,
28
+ } from "./rich-text-spellcheck.js";
29
+
30
+ let dom: JSDOM;
31
+ let container: HTMLElement;
32
+ let root: Root;
33
+ let act: typeof reactAct;
34
+ let createElement: typeof reactCreateElement;
35
+ let createRoot: typeof reactCreateRoot;
36
+ let ComposeBody: typeof ComposeBodyType;
37
+
38
+ /** What the published image stages, per `REMIT_SPELLCHECK_LANGUAGES`. */
39
+ const BUILT = ["en", "en-GB", "nl"];
40
+
41
+ /** An English browser, which is what a Dutch writer routinely reads mail on. */
42
+ const BROWSER = ["en-US", "en"];
43
+
44
+ const DUTCH = "OK nou dank je wel hoor flapsigaar";
45
+
46
+ class Marks {
47
+ readonly ranges: Range[] = [];
48
+ add(range: Range): void {
49
+ this.ranges.push(range);
50
+ }
51
+ }
52
+
53
+ const recordingSpellcheck = (): {
54
+ options: SpellcheckOptions;
55
+ asked: string[];
56
+ } => {
57
+ const asked: string[] = [];
58
+ const options: SpellcheckOptions = {
59
+ provider: (language) => {
60
+ asked.push(language);
61
+ const provider: SpellProvider = {
62
+ language,
63
+ onStatus: (listener) => {
64
+ listener({ state: "ready", language });
65
+ return () => {};
66
+ },
67
+ check: (request) =>
68
+ Promise.resolve({
69
+ requestId: request.requestId,
70
+ revision: request.revision,
71
+ findings: [],
72
+ }),
73
+ suggest: (request) =>
74
+ Promise.resolve({
75
+ requestId: request.requestId,
76
+ word: request.word,
77
+ suggestions: [],
78
+ }),
79
+ close: () => {},
80
+ };
81
+ return Promise.resolve(provider);
82
+ },
83
+ };
84
+ return { options, asked };
85
+ };
86
+
87
+ const settle = async (): Promise<void> => {
88
+ await act(async () => {
89
+ await Promise.resolve();
90
+ });
91
+ await act(async () => {
92
+ await Promise.resolve();
93
+ });
94
+ };
95
+
96
+ /** Past the detection debounce, which is what the chip waits on. */
97
+ const detected = async (): Promise<void> => {
98
+ await act(async () => {
99
+ await new Promise((resolve) => setTimeout(resolve, 600));
100
+ });
101
+ await settle();
102
+ };
103
+
104
+ const editable = (): HTMLElement => {
105
+ const surface = container.querySelector<HTMLElement>(
106
+ "[data-testid=compose-body]",
107
+ );
108
+ if (!surface) throw new Error("the writing surface is not mounted");
109
+ return surface;
110
+ };
111
+
112
+ const chip = (): HTMLElement => {
113
+ const control = container.querySelector<HTMLElement>(
114
+ "[data-testid=compose-language-chip]",
115
+ );
116
+ if (!control) throw new Error("the language chip is not mounted");
117
+ return control;
118
+ };
119
+
120
+ before(async () => {
121
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
122
+ dom = new JSDOMCtor(
123
+ "<!doctype html><html><body><div id=root></div></body></html>",
124
+ { url: "http://localhost/", pretendToBeVisual: true },
125
+ );
126
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
127
+ globalThis.document = dom.window.document;
128
+ globalThis.HTMLElement = dom.window.HTMLElement;
129
+ globalThis.Element = dom.window.Element;
130
+ globalThis.Node = dom.window.Node;
131
+ globalThis.Event = dom.window.Event;
132
+ globalThis.MouseEvent = dom.window.MouseEvent;
133
+ globalThis.DOMParser = dom.window.DOMParser;
134
+ globalThis.MutationObserver = dom.window.MutationObserver;
135
+ globalThis.Range = dom.window.Range;
136
+ globalThis.AbortController = dom.window.AbortController;
137
+ globalThis.AbortSignal = dom.window.AbortSignal;
138
+ globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
139
+ globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(
140
+ dom.window,
141
+ );
142
+ globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(
143
+ dom.window,
144
+ );
145
+ Object.defineProperty(globalThis, "navigator", {
146
+ value: dom.window.navigator,
147
+ configurable: true,
148
+ });
149
+ // The marks are drawn through the CSS Custom Highlight registry, which jsdom
150
+ // has neither half of, and without it no checker is opened at all.
151
+ Object.defineProperty(globalThis, "CSS", {
152
+ value: { highlights: new Map<string, Marks>() },
153
+ configurable: true,
154
+ });
155
+ Object.defineProperty(globalThis, "Highlight", {
156
+ value: Marks,
157
+ configurable: true,
158
+ });
159
+ (
160
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
161
+ ).IS_REACT_ACT_ENVIRONMENT = true;
162
+
163
+ ({ act, createElement } = await import("react"));
164
+ ({ createRoot } = await import("react-dom/client"));
165
+ ({ ComposeBody } = await import("./compose-body.js"));
166
+ });
167
+
168
+ beforeEach(() => {
169
+ container = dom.window.document.createElement("div");
170
+ dom.window.document.body.append(container);
171
+ });
172
+
173
+ afterEach(async () => {
174
+ await act(async () => {
175
+ root.unmount();
176
+ });
177
+ container.remove();
178
+ });
179
+
180
+ after(() => {
181
+ dom.window.close();
182
+ });
183
+
184
+ const mount = async (
185
+ text: string,
186
+ languages: readonly string[],
187
+ spellcheck?: SpellcheckOptions,
188
+ ): Promise<void> => {
189
+ await act(async () => {
190
+ root = createRoot(container);
191
+ root.render(
192
+ createElement(ComposeBody, {
193
+ mode: "rich",
194
+ onModeChange: () => undefined,
195
+ initialHtml: `<p>${text}</p>`,
196
+ initialText: text,
197
+ onChange: () => undefined,
198
+ onConversionError: () => undefined,
199
+ onLanguageChange: () => undefined,
200
+ languages,
201
+ spellcheck,
202
+ }),
203
+ );
204
+ });
205
+ await settle();
206
+ };
207
+
208
+ describe("the language a message is written in", () => {
209
+ it("reads Dutch off the body of an account that never chose a language", async () => {
210
+ const { options, asked } = recordingSpellcheck();
211
+
212
+ await mount(DUTCH, defaultComposeLanguages(BROWSER, BUILT), options);
213
+ await detected();
214
+
215
+ assert.equal(chip().dataset.language, "nl");
216
+ assert.equal(chip().dataset.languageSource, "detected");
217
+ assert.equal(chip().textContent, "NL");
218
+ assert.equal(editable().getAttribute("lang"), "nl");
219
+ assert.equal(
220
+ asked.at(-1),
221
+ "nl",
222
+ "the checker follows the language, so the underlines are Dutch ones",
223
+ );
224
+ });
225
+
226
+ it("stays on the account default while the body is English", async () => {
227
+ await mount(
228
+ "Thanks a lot, I will send you a new proposal tomorrow.",
229
+ defaultComposeLanguages(BROWSER, BUILT),
230
+ );
231
+ await detected();
232
+
233
+ assert.equal(chip().dataset.language, "en");
234
+ assert.equal(editable().getAttribute("lang"), "en");
235
+ });
236
+ });
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
3
  import { expect, fn, userEvent, waitFor, within } from "storybook/test";
4
+ import { defaultComposeLanguages } from "../lib/compose-language.js";
4
5
  import { ComposeBody, type ConversionFailure } from "./compose-body.js";
5
6
  import type { RichTextValue } from "./rich-text-value.js";
6
7
 
@@ -411,6 +412,31 @@ export const DutchIsDetected: Story = {
411
412
  },
412
413
  };
413
414
 
415
+ /**
416
+ * The account that has never opened the language setting, read on an English
417
+ * browser. Its candidate set is the browser's answer and the dictionaries this
418
+ * build carries — a set of one would be detection switched off, and a Dutch
419
+ * message would keep the English tag and the English underlines.
420
+ */
421
+ export const UnconfiguredAccountStillReadsDutch: Story = {
422
+ name: "Dutch on an English browser, nothing configured",
423
+ args: {
424
+ initialHtml: DUTCH_DOCUMENT,
425
+ languages: defaultComposeLanguages(["en-US", "en"], ["en", "en-GB", "nl"]),
426
+ },
427
+ play: async ({ canvasElement }) => {
428
+ await waitFor(
429
+ async () => {
430
+ await expect(chipOf(canvasElement)).toHaveTextContent("NL");
431
+ },
432
+ { timeout: 5000 },
433
+ );
434
+ await expect(
435
+ canvasElement.querySelector("[data-testid=compose-body]"),
436
+ ).toHaveAttribute("lang", "nl");
437
+ },
438
+ };
439
+
414
440
  /** Under twenty characters detection is a coin toss, so the account default stands. */
415
441
  export const TooShortHoldsTheDefault: Story = {
416
442
  name: "Nine characters hold the default",
@@ -45,6 +45,65 @@ describe("ComposeFormShell", () => {
45
45
  assert.doesNotMatch(html, /pb-2/);
46
46
  });
47
47
 
48
+ it("scrolls the body region when it fills the height it is given", () => {
49
+ const html = renderToString(
50
+ createElement(ComposeFormShell, {
51
+ layout: "fill",
52
+ header: createElement("div", null, "H"),
53
+ actionBar: createElement("div", null, "B"),
54
+ // biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
55
+ children: createElement("div", null, "BODY"),
56
+ }),
57
+ );
58
+ assert.match(html, /overflow-auto/);
59
+ });
60
+
61
+ it("scrolls nothing when it takes the height of what is written in it", () => {
62
+ const html = renderToString(
63
+ createElement(ComposeFormShell, {
64
+ layout: "flow",
65
+ header: createElement("div", null, "H"),
66
+ quoted: createElement("div", null, "QUOTED"),
67
+ actionBar: createElement("div", null, "B"),
68
+ // biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
69
+ children: createElement("div", null, "BODY"),
70
+ }),
71
+ );
72
+ // A surface opened as a block of a page it did not bring: a scroller here
73
+ // is a second track in that page's column, with the caret inside it.
74
+ assert.doesNotMatch(html, /overflow-/);
75
+ assert.doesNotMatch(html, /h-full/);
76
+ });
77
+
78
+ it("keeps the action bar on the bottom edge while the surface grows", () => {
79
+ const html = renderToString(
80
+ createElement(ComposeFormShell, {
81
+ layout: "flow",
82
+ header: createElement("div", null, "H"),
83
+ actionBar: createElement("div", null, "B"),
84
+ // biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
85
+ children: createElement("div", null, "BODY"),
86
+ }),
87
+ );
88
+ // A draft of a few paragraphs is taller than a phone, and the bar is the
89
+ // last thing in the surface: without this Send goes off the bottom as it
90
+ // is written.
91
+ assert.match(html, /sticky bottom-0/);
92
+ });
93
+
94
+ it("leaves the action bar in the column when the surface fills its pane", () => {
95
+ const html = renderToString(
96
+ createElement(ComposeFormShell, {
97
+ layout: "fill",
98
+ header: createElement("div", null, "H"),
99
+ actionBar: createElement("div", null, "B"),
100
+ // biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
101
+ children: createElement("div", null, "BODY"),
102
+ }),
103
+ );
104
+ assert.doesNotMatch(html, /sticky/);
105
+ });
106
+
48
107
  it("exposes mode labels for every compose mode", () => {
49
108
  assert.equal(composeModeLabels.new, "New Message");
50
109
  assert.equal(composeModeLabels.reply, "Reply");
@@ -9,6 +9,15 @@ export const composeModeLabels: Record<ComposeMode, string> = {
9
9
  forward: "Forward",
10
10
  };
11
11
 
12
+ /**
13
+ * How the surface gets its height. `fill` takes the one its container hands it
14
+ * and scrolls the body inside that, so the action bar is pinned to the bottom
15
+ * edge — a window's shape. `flow` takes the height of what is written in it and
16
+ * has no scroller of its own, so the surface grows downward and whatever it
17
+ * sits in is the only thing that scrolls.
18
+ */
19
+ export type ComposeShellLayout = "fill" | "flow";
20
+
12
21
  export interface ComposeFormShellProps {
13
22
  /** Optional banner above the header (e.g. SMTP-missing notice). */
14
23
  banner?: ReactNode;
@@ -20,17 +29,26 @@ export interface ComposeFormShellProps {
20
29
  quoted?: ReactNode;
21
30
  /** The ComposeActionBar. */
22
31
  actionBar: ReactNode;
32
+ layout?: ComposeShellLayout;
23
33
  }
24
34
 
25
35
  /**
26
- * Presentational compose layout: banner / header / scrollable body+quote /
27
- * pinned action bar. Owns the column structure so the action bar always sits
28
- * at the bottom and never clips below the fold. The live form composes this
29
- * with its provider-driven slots; stories render it with static slots.
36
+ * Presentational compose layout: banner / header / body+quote / action bar.
37
+ *
38
+ * In `fill` the body region is a column so the editor can claim the space the
39
+ * quote and the action bar leave a body slot shorter than the region would
40
+ * otherwise leave dead, unclickable canvas under it.
41
+ *
42
+ * In `flow` nothing here scrolls. A composer that is a block of the page it was
43
+ * opened on must not bring a second scroller into that pane: two tracks in one
44
+ * column, with the caret in the inner one, is the reader guessing which of them
45
+ * a wheel gesture belongs to.
30
46
  *
31
- * The body region is a column so the editor can claim the space the quote and
32
- * the action bar leave a body slot shorter than the region would otherwise
33
- * leave dead, unclickable canvas under it.
47
+ * A surface that grows with the writing grows past the screen on a phone after
48
+ * a few paragraphs, and the action bar is the last thing in it so in `flow`
49
+ * the bar rides the bottom edge of whatever scrolls the page while the surface
50
+ * is on it. Send is reachable at every length of draft, and lands back in the
51
+ * column when the end of the composer comes into view.
34
52
  */
35
53
  export function ComposeFormShell({
36
54
  banner,
@@ -38,19 +56,27 @@ export function ComposeFormShell({
38
56
  children,
39
57
  quoted,
40
58
  actionBar,
59
+ layout = "fill",
41
60
  }: ComposeFormShellProps) {
61
+ const fills = layout === "fill";
42
62
  return (
43
- <div className="flex h-full min-h-0 flex-col">
63
+ <div className={fills ? "flex h-full min-h-0 flex-col" : "flex flex-col"}>
44
64
  {banner}
45
65
  {header}
46
66
  <div
47
- className="flex min-h-0 flex-1 flex-col overflow-auto"
67
+ className={
68
+ fills ? "flex min-h-0 flex-1 flex-col overflow-auto" : "flex flex-col"
69
+ }
48
70
  data-testid="compose-body-area"
49
71
  >
50
72
  {children}
51
73
  {quoted && <div className="shrink-0 px-3 pb-2">{quoted}</div>}
52
74
  </div>
53
- {actionBar}
75
+ {fills ? (
76
+ actionBar
77
+ ) : (
78
+ <div className="sticky bottom-0 z-10 bg-canvas">{actionBar}</div>
79
+ )}
54
80
  </div>
55
81
  );
56
82
  }
@@ -24,23 +24,79 @@ const FONT_STACK =
24
24
  * iframe boundary. Keep in sync with `tokens.css`; the unit test pins these
25
25
  * resolved values so token drift fails loudly.
26
26
  *
27
- * --fg light: oklch(0.3 0.025 235) dark: oklch(0.88 0.02 90)
28
- * --surface light: oklch(0.975 0.012 90) dark: oklch(0.25 0.025 220)
29
- * --accent light: oklch(0.55 0.14 150) dark: oklch(0.78 0.16 150)
27
+ * `canvas` is the reading pane's own ground, not `surface`: whenever the app
28
+ * supplies the background the frame must be indistinguishable from the pane
29
+ * around it, or the email reads as a lighter rectangle sitting inside the pane.
30
+ *
31
+ * --fg light: oklch(0.3 0.025 235) dark: oklch(0.88 0.02 90)
32
+ * --canvas light: oklch(0.96 0.015 90) dark: oklch(0.22 0.025 220)
33
+ * --accent light: oklch(0.55 0.14 150) dark: oklch(0.78 0.16 150)
30
34
  */
31
- const PLAIN_TOKENS = {
35
+ const FRAME_TOKENS = {
32
36
  light: {
33
37
  fg: "oklch(0.3 0.025 235)",
34
- surface: "oklch(0.975 0.012 90)",
38
+ canvas: "oklch(0.96 0.015 90)",
35
39
  accent: "oklch(0.55 0.14 150)",
36
40
  },
37
41
  dark: {
38
42
  fg: "oklch(0.88 0.02 90)",
39
- surface: "oklch(0.25 0.025 220)",
43
+ canvas: "oklch(0.22 0.025 220)",
40
44
  accent: "oklch(0.78 0.16 150)",
41
45
  },
42
46
  } as const;
43
47
 
48
+ /**
49
+ * What the mail declares about its own presentation. Both facts are read off
50
+ * the raw author markup by the sanitizer, and both decide whether the app
51
+ * supplies something or stands back:
52
+ *
53
+ * - `background` — the mail paints its own ground, so it renders as authored.
54
+ * When it declares none, the app's ground is the reading pane's own colour.
55
+ * - `spacing` — the mail lays out its own padding or margin. When it declares
56
+ * none, the app injects breathing room INSIDE the background, so the ground
57
+ * still runs edge to edge and only the text is inset.
58
+ */
59
+ export interface AuthorDeclarations {
60
+ background: boolean;
61
+ spacing: boolean;
62
+ }
63
+
64
+ const NOTHING_DECLARED: AuthorDeclarations = {
65
+ background: false,
66
+ spacing: false,
67
+ };
68
+
69
+ /**
70
+ * The inset between the email's own ground and its text, for mail that lays out
71
+ * none of its own. Matches the reading pane's desktop gutter, so a bare message
72
+ * reads with the same rhythm as the chrome above it.
73
+ */
74
+ const CONTENT_INSET = "16px";
75
+
76
+ /**
77
+ * Breathing room for a mail that brings none, painted inside the element that
78
+ * carries the background rather than around it. Emitted LAST in the document so
79
+ * it beats the layout clamp's `padding: 0` reset; `border-box` keeps the padded
80
+ * body inside the frame instead of pushing 32px of content past its right edge.
81
+ */
82
+ export const generateContentInsetCSS = (): string =>
83
+ `html{padding:0}body{padding:${CONTENT_INSET};box-sizing:border-box}`;
84
+
85
+ /**
86
+ * The document is its own scrollport. The frame is exactly as wide as the pane
87
+ * and is never widened to fit the mail, so content that genuinely cannot wrap —
88
+ * a fixed-width table, an image with its own `min-width`, a `pre` the author
89
+ * pinned — has to be reachable from inside the document rather than by handing
90
+ * the app a wider box to hold.
91
+ *
92
+ * `body` only becomes a scroll container once `html` stops being `visible`:
93
+ * overflow set on the body propagates to the viewport otherwise, which would put
94
+ * the scrollbar back on the frame itself. Pinning `html` first is what keeps the
95
+ * scroll where the content is.
96
+ */
97
+ export const generateScrollportCSS = (): string =>
98
+ "html{overflow:hidden}body{overflow-x:auto}";
99
+
44
100
  /**
45
101
  * Detects whether a (sanitized) email opts into dark rendering — either via a
46
102
  * `prefers-color-scheme: dark` media query or an explicit `color-scheme: dark`
@@ -65,7 +121,7 @@ export const VIEWPORT_META =
65
121
  * letting font-size / weight variations through, and re-themes links.
66
122
  */
67
123
  export const generatePlainEmailBaseCSS = (isDark: boolean): string => {
68
- const t = isDark ? PLAIN_TOKENS.dark : PLAIN_TOKENS.light;
124
+ const t = isDark ? FRAME_TOKENS.dark : FRAME_TOKENS.light;
69
125
  return `
70
126
  /* Plain-email base: UI font-stack + theme-aware colors (#424) */
71
127
  html, body {
@@ -73,7 +129,7 @@ html, body {
73
129
  font-size: 14px;
74
130
  line-height: 1.6;
75
131
  color: ${t.fg};
76
- background-color: ${t.surface};
132
+ background-color: ${t.canvas};
77
133
  margin: 0;
78
134
  padding: 0;
79
135
  }
@@ -90,48 +146,73 @@ a, a:visited {
90
146
  `;
91
147
  };
92
148
 
149
+ const SMART_INVERT = "invert(0.92) hue-rotate(180deg)";
150
+ const RE_INVERT_MEDIA = `img,picture,video,svg,canvas,[style*='background-image'],[background]{filter:${SMART_INVERT}}`;
151
+
93
152
  /**
94
- * Framed-email base CSS, mirroring K-9 Mail's dark-reading strategy. Three
95
- * cases, driven by app theme + the email's own dark opt-in:
153
+ * Framed-email base CSS, mirroring K-9 Mail's dark-reading strategy, split
154
+ * first on whether the mail brought a ground of its own.
155
+ *
156
+ * The mail declared one — render it as authored: white canvas in light, and in
157
+ * dark either the author's own dark design or the smart-invert that darkens a
158
+ * white-assuming email into the pane.
96
159
  *
97
- * - App light theme render as authored on a white canvas (light scheme).
98
- * - App dark theme + email opts into its own dark render as authored.
99
- * - App dark theme, no opt-in CSS smart-invert to darken the email into the
100
- * dark pane: soft charcoal background, light-grey text.
160
+ * The mail declared none the app supplies the ground, and an app-supplied
161
+ * ground is the reading pane's own colour, so the email is one surface with the
162
+ * pane rather than a rectangle inside it. In dark the invert moves off `html`
163
+ * onto `body`: the author's colours still darken, but the canvas behind them is
164
+ * the pane's and is never inverted into a white-turned-charcoal slab.
101
165
  *
102
166
  * `invert(0.92)` (not 1.0) lands on soft charcoal + light-grey like K-9 rather
103
- * than pure black/white; the white `html` background gives the invert a light
104
- * source; `hue-rotate(180deg)` keeps blues blue (links); the media-element rule
105
- * re-inverts images / logos / photos back to their natural colours. No
106
- * `!important` this is a default canvas, so the email's own background still
107
- * composes. The margin reset zeroes the UA body margin.
167
+ * than pure black/white; `hue-rotate(180deg)` keeps blues blue (links); the
168
+ * media-element rule re-inverts images / logos / photos back to their natural
169
+ * colours. No `!important` this is a default canvas, so the email's own
170
+ * background still composes. The margin reset zeroes the UA body margin.
108
171
  */
109
172
  export const generateFramedEmailBaseCSS = (
110
173
  isDark: boolean,
111
174
  optsIntoDark: boolean,
175
+ hasAuthorBackground: boolean,
112
176
  ): string => {
177
+ const canvas = isDark ? FRAME_TOKENS.dark.canvas : FRAME_TOKENS.light.canvas;
178
+ if (!hasAuthorBackground) {
179
+ if (!isDark)
180
+ return `html,body{margin:0;background-color:${canvas};color-scheme:light}`;
181
+ if (optsIntoDark)
182
+ return `html,body{margin:0;background-color:${canvas};color-scheme:dark light}`;
183
+ return `html{margin:0;background-color:${canvas}}body{margin:0;filter:${SMART_INVERT}}${RE_INVERT_MEDIA}`;
184
+ }
113
185
  if (!isDark)
114
186
  return "html,body{margin:0;background-color:#ffffff;color-scheme:light}";
115
- if (optsIntoDark) return "html,body{margin:0;color-scheme:dark light}";
116
- return "html{margin:0;background-color:#ffffff;filter:invert(0.92) hue-rotate(180deg)}body{margin:0}img,picture,video,svg,canvas,[style*='background-image'],[background]{filter:invert(0.92) hue-rotate(180deg)}";
187
+ if (optsIntoDark)
188
+ return `html,body{margin:0;background-color:${canvas};color-scheme:dark light}`;
189
+ return `html{margin:0;background-color:#ffffff;filter:${SMART_INVERT}}body{margin:0}${RE_INVERT_MEDIA}`;
117
190
  };
118
191
 
119
192
  export type EmailFrameVariant = "plain" | "framed";
120
193
 
121
194
  /**
122
195
  * Assemble the full srcDoc for an isolated email frame: the viewport meta, the
123
- * treatment's base CSS, then the (already sanitized, layout-clamped) email
124
- * HTML. This is the single place the colour / font / dark-mode decision is
125
- * applied callers pass treatment + theme, never raw CSS.
196
+ * treatment's base CSS, the (already sanitized, layout-clamped) email HTML, and
197
+ * last where they outrank the clamp's own resets the scrollport and, for
198
+ * mail that lays out none of its own, the content inset. This is the single
199
+ * place the colour / font / dark-mode / spacing decision is applied; callers
200
+ * pass treatment, theme and what the mail declares, never raw CSS.
126
201
  */
127
202
  export const buildEmailSrcDoc = (
128
203
  html: string,
129
204
  variant: EmailFrameVariant,
130
205
  isDark: boolean,
206
+ declares: AuthorDeclarations = NOTHING_DECLARED,
131
207
  ): string => {
132
- if (variant === "plain") {
133
- return `${VIEWPORT_META}<style>${generatePlainEmailBaseCSS(isDark)}</style>${html}`;
134
- }
135
- const optsIntoDark = DARK_OPT_IN_RE.test(html);
136
- return `${VIEWPORT_META}<style>${generateFramedEmailBaseCSS(isDark, optsIntoDark)}</style>${html}`;
208
+ const base =
209
+ variant === "plain"
210
+ ? generatePlainEmailBaseCSS(isDark)
211
+ : generateFramedEmailBaseCSS(
212
+ isDark,
213
+ DARK_OPT_IN_RE.test(html),
214
+ declares.background,
215
+ );
216
+ const inset = declares.spacing ? "" : generateContentInsetCSS();
217
+ return `${VIEWPORT_META}<style>${base}</style>${html}<style>${generateScrollportCSS()}${inset}</style>`;
137
218
  };