@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,6 +1,9 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
- import { useMatchMedia } from "../lib/use-match-media.js";
3
- import { buildEmailSrcDoc, type EmailFrameVariant } from "./email-frame-css.js";
2
+ import {
3
+ type AuthorDeclarations,
4
+ buildEmailSrcDoc,
5
+ type EmailFrameVariant,
6
+ } from "./email-frame-css.js";
4
7
 
5
8
  export interface IsolatedEmailFrameProps {
6
9
  /**
@@ -26,6 +29,13 @@ export interface IsolatedEmailFrameProps {
26
29
  * or apply the smart-invert.
27
30
  */
28
31
  isDark?: boolean;
32
+ /**
33
+ * What the mail declares about its own presentation, from the sanitizer. A
34
+ * declared background renders as authored; where the mail declares none the
35
+ * frame's ground is the reading pane's own colour. A mail that declares no
36
+ * padding or margin is given breathing room inside that ground.
37
+ */
38
+ declares?: AuthorDeclarations;
29
39
  className?: string;
30
40
  }
31
41
 
@@ -34,36 +44,21 @@ export interface IsolatedEmailFrameProps {
34
44
  // preferable to letting a hostile sender allocate unbounded layout.
35
45
  const MAX_HEIGHT_PX = 50_000;
36
46
 
37
- // Same spirit as MAX_HEIGHT_PX for the horizontal axis. Fixed-width newsletters
38
- // top out around 900px; well past that a hostile sender is the likely cause.
39
- const MAX_WIDTH_PX = 10_000;
40
-
41
- // Below this we are on a phone: a wide fixed-layout email that cannot reflow is
42
- // scaled down to fit the container instead of being clipped (#727). Wider
43
- // viewports keep the content-width pin so multi-column newsletters render at
44
- // their native width and the pane scrolls horizontally.
45
- const NARROW_QUERY = "(max-width: 640px)";
46
-
47
- // Don't scale below this — a heavily fixed-width newsletter on a tiny phone
48
- // would otherwise shrink to unreadable. At the floor we accept that the email
49
- // is downscaled as far as we'll go and the wrapper still clips the remainder
50
- // (text stays larger and legible, edge content is sacrificed over a 3x shrink).
51
- const MIN_SCALE = 0.4;
52
-
53
47
  // sandbox flags: scripts blocked (DOMPurify already strips them; defence in
54
48
  // depth), forms blocked, top navigation blocked. `allow-popups` +
55
49
  // `allow-popups-to-escape-sandbox` lets `target="_blank"` links open in a new
56
50
  // tab. `allow-same-origin` is required so the parent can read
57
- // `contentDocument.body` to size the iframe to its content — safe without
51
+ // `contentDocument.body` to give the frame its content's height — safe without
58
52
  // `allow-scripts` since there is no JS in the frame to exploit it.
59
53
  const SANDBOX = "allow-same-origin allow-popups allow-popups-to-escape-sandbox";
60
54
 
61
55
  /**
62
- * Pin an iframe axis to its content's scroll size: take the larger of the body
63
- * and documentElement scroll sizes, round UP so a fractional content size never
64
- * leaves a 1px phantom overflow, and cap at `max` so a hostile sender can't
65
- * allocate unbounded layout. Returned value is the explicit px the iframe is
66
- * sized to on that axis.
56
+ * Pin the frame's height to its content's scroll size: take the larger of the
57
+ * body and documentElement scroll sizes, round UP so a fractional content size
58
+ * never leaves a 1px phantom overflow, and cap at `max` so a hostile sender
59
+ * can't allocate unbounded layout. Returned value is the explicit px the iframe
60
+ * is sized to. A seamless inline frame has to auto-size vertically; the width is
61
+ * the pane's and is never read off the mail.
67
62
  */
68
63
  export const measureContentAxis = (
69
64
  bodyScroll: number,
@@ -71,22 +66,6 @@ export const measureContentAxis = (
71
66
  max: number,
72
67
  ): number => Math.min(Math.ceil(Math.max(bodyScroll, rootScroll)), max);
73
68
 
74
- /**
75
- * The fit-to-width scale for a phone: downscale-only, so content already inside
76
- * the container renders 1:1 and only genuinely-wider content shrinks. Floored at
77
- * `MIN_SCALE` so a pathologically wide email doesn't shrink to unreadable. A
78
- * non-positive or unknown width yields `1` (no scale) so we never divide by zero
79
- * or upscale before the first measurement lands.
80
- */
81
- export const computeFitScale = (
82
- contentWidth: number,
83
- containerWidth: number,
84
- ): number => {
85
- if (contentWidth <= 0 || containerWidth <= 0) return 1;
86
- if (contentWidth <= containerWidth) return 1;
87
- return Math.max(MIN_SCALE, containerWidth / contentWidth);
88
- };
89
-
90
69
  /** Named (non-character) keys worth replaying: moving around and closing. */
91
70
  const FORWARDED_NAMED_KEYS = new Set([
92
71
  "Enter",
@@ -143,53 +122,37 @@ const forwardKeyDown = (event: KeyboardEvent) => {
143
122
  };
144
123
 
145
124
  /**
146
- * Render untrusted (sanitized) email HTML in a sandboxed iframe that fits the
147
- * viewport width on mobile and isolates the email's CSS from the app chrome.
125
+ * Render untrusted (sanitized) email HTML in a sandboxed iframe that is exactly
126
+ * as wide as the pane holding it and isolates the email's CSS from the app
127
+ * chrome.
148
128
  *
149
- * Presentational: HTML + treatment + theme come in via props; the component
150
- * owns the srcDoc assembly, the content-sizing, and the fit-to-viewport
151
- * decision in one place. The frame sizes itself to its content via a
152
- * ResizeObserver so it grows no internal scrollbars vertical scrolling and
153
- * (on desktop) horizontal scrolling of genuinely wide email are delegated to
154
- * the surrounding pane.
129
+ * Presentational: HTML + treatment + theme come in via props; the component owns
130
+ * the srcDoc assembly and the height. The width is the app's layout and nothing
131
+ * else the frame is never widened to fit the mail, so no measurement of the
132
+ * email can move a box the reader can see. Content that genuinely cannot wrap (a
133
+ * fixed-width table, an oversized image, a `pre` the author pinned) scrolls
134
+ * inside the document, where it lives; the pane and the page never learn about
135
+ * it.
155
136
  *
156
- * On a phone a fixed-layout email that *can't* reflow (an inline
157
- * `min-width:600px` on a `<td>` beats the sanitizer's clamp) is rendered at its
158
- * natural width and the whole iframe is CSS-scaled down to fit the container —
159
- * the email stays whole and readable instead of being clipped (#727).
137
+ * Height is the one axis the frame reads off its content: a seamless inline
138
+ * frame has to grow to the mail it shows or it would scroll internally against
139
+ * the page's own scrollbar.
160
140
  */
161
141
  export const IsolatedEmailFrame = ({
162
142
  html,
163
143
  variant = "framed",
164
144
  isDark = false,
145
+ declares,
165
146
  className,
166
147
  }: IsolatedEmailFrameProps) => {
167
- const hostRef = useRef<HTMLDivElement>(null);
168
148
  const ref = useRef<HTMLIFrameElement>(null);
169
149
  const [height, setHeight] = useState(0);
170
- const [width, setWidth] = useState(0);
171
- const [containerWidth, setContainerWidth] = useState(0);
172
-
173
- const isNarrow = useMatchMedia(NARROW_QUERY);
174
150
 
175
151
  const srcDoc = useMemo(
176
- () => buildEmailSrcDoc(html, variant, isDark),
177
- [html, variant, isDark],
152
+ () => buildEmailSrcDoc(html, variant, isDark, declares),
153
+ [html, variant, isDark, declares],
178
154
  );
179
155
 
180
- useEffect(() => {
181
- const host = hostRef.current;
182
- if (!host) return;
183
- const measure = () =>
184
- setContainerWidth((prev) =>
185
- prev === host.clientWidth ? prev : host.clientWidth,
186
- );
187
- measure();
188
- const observer = new ResizeObserver(measure);
189
- observer.observe(host);
190
- return () => observer.disconnect();
191
- }, []);
192
-
193
156
  useEffect(() => {
194
157
  const iframe = ref.current;
195
158
  if (!iframe) return;
@@ -198,29 +161,39 @@ export const IsolatedEmailFrame = ({
198
161
  const doc = iframe.contentDocument;
199
162
  if (!doc?.body) return;
200
163
  const root = doc.documentElement;
201
- const nextHeight = measureContentAxis(
164
+ const next = measureContentAxis(
202
165
  doc.body.scrollHeight,
203
166
  root?.scrollHeight ?? 0,
204
167
  MAX_HEIGHT_PX,
205
168
  );
206
- setHeight((prev) => (prev === nextHeight ? prev : nextHeight));
207
- const nextWidth = measureContentAxis(
208
- doc.body.scrollWidth,
209
- root?.scrollWidth ?? 0,
210
- MAX_WIDTH_PX,
211
- );
212
- setWidth((prev) => (prev === nextWidth ? prev : nextWidth));
169
+ setHeight((prev) => (prev === next ? prev : next));
213
170
  };
214
171
 
215
172
  let observer: ResizeObserver | undefined;
216
173
  let keyDoc: Document | undefined;
217
174
  const handleLoad = () => {
218
175
  measure();
176
+ // The srcDoc is rebuilt whenever the mail, theme or treatment changes,
177
+ // so this fires again for a new document; the observer watching the old
178
+ // one has to go with it.
179
+ observer?.disconnect();
180
+ observer = undefined;
181
+ keyDoc?.removeEventListener("keydown", forwardKeyDown);
182
+ keyDoc?.removeEventListener("load", measure, true);
183
+ keyDoc = undefined;
219
184
  const doc = iframe.contentDocument;
220
185
  if (!doc?.body) return;
221
186
  observer = new ResizeObserver(measure);
222
187
  observer.observe(doc.body);
223
188
  if (doc.documentElement) observer.observe(doc.documentElement);
189
+ // A ResizeObserver watches the body's BOX, which reflows with the pane
190
+ // but not with its own content. Content that arrives late — an image, a
191
+ // webfont that re-flows the text taller — changes the scroll size
192
+ // underneath a box that never moves, so without these the frame keeps a
193
+ // height it took before the mail finished laying out and clips the
194
+ // difference.
195
+ doc.addEventListener("load", measure, true);
196
+ doc.fonts?.ready.then(measure);
224
197
  doc.addEventListener("keydown", forwardKeyDown);
225
198
  keyDoc = doc;
226
199
  };
@@ -229,51 +202,23 @@ export const IsolatedEmailFrame = ({
229
202
  return () => {
230
203
  iframe.removeEventListener("load", handleLoad);
231
204
  keyDoc?.removeEventListener("keydown", forwardKeyDown);
205
+ keyDoc?.removeEventListener("load", measure, true);
232
206
  observer?.disconnect();
233
207
  };
234
208
  }, []);
235
209
 
236
- // The fit-to-viewport decision, owned in one place:
237
- // - Phone (`isNarrow`): render the iframe at its natural content width and
238
- // CSS-scale the whole frame down to the container, so a fixed-width
239
- // newsletter that can't reflow fits the phone whole instead of being
240
- // clipped (#727). Content already within the container renders 1:1.
241
- // - Desktop framed: `max(100%, content)` so a narrow-max-width newsletter
242
- // (Substack's 640px body) fills the reading column while a genuinely wide
243
- // fixed-layout email grows past the pane and lets the pane scroll.
244
- // - Plain / pre-measurement: pin to measured content width, 100% until known.
245
- const scale = isNarrow ? computeFitScale(width, containerWidth) : 1;
246
- const scaled = scale < 1;
247
-
248
- const frameWidth = scaled
249
- ? `${width}px`
250
- : isNarrow
251
- ? "100%"
252
- : variant === "framed" && width > 0
253
- ? `max(100%, ${width}px)`
254
- : width === 0
255
- ? "100%"
256
- : `${width}px`;
257
-
258
- const frameHeight = height === 0 ? "1px" : `${height}px`;
259
-
260
- const iframe = (
210
+ return (
261
211
  <iframe
262
212
  ref={ref}
263
213
  title="Email content"
264
214
  sandbox={SANDBOX}
265
215
  srcDoc={srcDoc}
266
- className={scaled ? undefined : className}
267
- scrolling="no"
216
+ className={className}
268
217
  style={{
269
- width: frameWidth,
270
- maxWidth: scaled ? "none" : undefined,
218
+ width: "100%",
271
219
  border: "none",
272
220
  display: "block",
273
- height: frameHeight,
274
- overflow: "hidden",
275
- transform: scaled ? `scale(${scale})` : undefined,
276
- transformOrigin: scaled ? "top left" : undefined,
221
+ height: height === 0 ? "1px" : `${height}px`,
277
222
  // Both branches carry their own color-scheme (and, for the framed
278
223
  // dark-invert case, the darkening filter) in the injected base CSS,
279
224
  // so the iframe element stays "normal" rather than pinning a scheme
@@ -282,26 +227,4 @@ export const IsolatedEmailFrame = ({
282
227
  }}
283
228
  />
284
229
  );
285
-
286
- // When scaled, the iframe's layout box stays its natural (un-transformed)
287
- // size, so it must sit in a wrapper sized to the SCALED footprint and clip
288
- // the overflow — otherwise the surrounding pane sees the natural width and
289
- // grows a scrollbar.
290
- return (
291
- <div ref={hostRef} className={scaled ? className : undefined}>
292
- {scaled ? (
293
- <div
294
- style={{
295
- width: "100%",
296
- height: `${Math.ceil(height * scale)}px`,
297
- overflow: "hidden",
298
- }}
299
- >
300
- {iframe}
301
- </div>
302
- ) : (
303
- iframe
304
- )}
305
- </div>
306
- );
307
230
  };
@@ -1,5 +1,6 @@
1
1
  import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { MessageBodyView } from "./message-body-view.js";
3
+ import { ExpandedMessage } from "./reading-pane.js";
3
4
 
4
5
  /**
5
6
  * `MessageBodyView` is the single source of truth for rendering an email body:
@@ -7,7 +8,7 @@ import { MessageBodyView } from "./message-body-view.js";
7
8
  * as framed (designed mail) or plain (theme-aware), and hands the result to the
8
9
  * sandboxed `IsolatedEmailFrame`. The app's `MessageBody` and the kit reading
9
10
  * panes both compose it, so Storybook renders email exactly as the app does
10
- * (#940) — sandbox, flush layout and #727 scale-to-fit all visible.
11
+ * (#940) — sandbox, flush layout and in-document overflow all visible.
11
12
  *
12
13
  * Unlike the `IsolatedEmailFrame` stories, these fixtures pass RAW author HTML:
13
14
  * the component runs the real sanitizer, so the layout-clamp `<style>` and the
@@ -43,6 +44,44 @@ const PLAIN = `
43
44
  </div>
44
45
  `;
45
46
 
47
+ // Formatted HTML mail that declares nothing: no background, no padding, no
48
+ // width. The app supplies the ground, so the ground must be the pane's own and
49
+ // the breathing room must sit inside it — one surface with comfortable margins,
50
+ // not a lighter rectangle seamed into the pane.
51
+ const UNSTYLED_HTML = `
52
+ <div>
53
+ <p>Hoi allemaal,</p>
54
+ <p>De repetitie van donderdag gaat door. We beginnen met het nieuwe stuk en
55
+ repeteren om 20.00 uur verder aan het programma voor het najaarsconcert.</p>
56
+ <p><b>Neem je eigen partituur mee</b> — er zijn geen reservekopieën.</p>
57
+ <p>Groeten,<br>Ingrid</p>
58
+ </div>
59
+ `;
60
+
61
+ // The same mail with a nowrap its author never expected a client to honour:
62
+ // flowing text pinned to one line would scroll sideways forever, so the clamp
63
+ // wraps it to the frame instead.
64
+ const UNSTYLED_HTML_NOWRAP = `
65
+ <div style="white-space:nowrap">
66
+ <p>Hoi allemaal,</p>
67
+ <p>De repetitie van donderdag gaat door. We beginnen met het nieuwe stuk en repeteren om 20.00 uur verder aan het programma voor het najaarsconcert.</p>
68
+ <p>Groeten,<br>Ingrid</p>
69
+ </div>
70
+ `;
71
+
72
+ // A short plain-text message: no HTML part at all. It is not an email document
73
+ // and has no ground of its own, so it keeps the reading pane's gutter rather
74
+ // than running to the pane edge the way a sandboxed frame does.
75
+ const PLAIN_TEXT = `Hoi allemaal,
76
+
77
+ De repetitie van donderdag gaat door. We beginnen met het nieuwe stuk en
78
+ repeteren om 20.00 uur verder aan het programma voor het najaarsconcert.
79
+
80
+ Neem je eigen partituur mee - er zijn geen reservekopieen.
81
+
82
+ Groeten,
83
+ Ingrid`;
84
+
46
85
  // Marketing mail with two remote images — with images blocked the sanitizer
47
86
  // swaps them for placeholders and the privacy notice slot reports the count.
48
87
  const WITH_REMOTE_IMAGES = `
@@ -54,18 +93,91 @@ const WITH_REMOTE_IMAGES = `
54
93
  </div>
55
94
  `;
56
95
 
96
+ // A short personal note: a few lines of text, nowhere near the width of the
97
+ // reading column. Nothing here can overflow, so the pane must show no
98
+ // horizontal scrollbar at any column width.
99
+ const SHORT_NOTE = `
100
+ <div>
101
+ <p>Booked. See you at 3.</p>
102
+ </div>
103
+ `;
104
+
105
+ // Real mail that genuinely does not fit: a report table whose inline min-width
106
+ // beats the sanitizer's clamp (an inline style outranks its `* { min-width: 0 }`),
107
+ // so the table stays 1200px wide however narrow the column is and has to be
108
+ // reachable from inside the frame.
109
+ const WIDE_TABLE = `
110
+ <div style="font-family: Helvetica, Arial, sans-serif; color:#1a1a1a;">
111
+ <h1 style="font-size:20px;margin:0 0 12px;">Q2 regional breakdown</h1>
112
+ <table cellpadding="8" cellspacing="0" style="min-width:1200px;border-collapse:collapse;">
113
+ <tr style="background:#efefef;">
114
+ <th style="min-width:200px;text-align:left;">Region</th>
115
+ <th style="min-width:200px;text-align:left;">Pipeline</th>
116
+ <th style="min-width:200px;text-align:left;">Closed won</th>
117
+ <th style="min-width:200px;text-align:left;">Closed lost</th>
118
+ <th style="min-width:200px;text-align:left;">Forecast</th>
119
+ <th style="min-width:200px;text-align:left;">Owner</th>
120
+ </tr>
121
+ <tr>
122
+ <td>Benelux</td><td>€1.2M</td><td>€480k</td><td>€120k</td><td>€1.6M</td><td>Sanne de Vries</td>
123
+ </tr>
124
+ <tr style="background:#f8f8f8;">
125
+ <td>DACH</td><td>€2.4M</td><td>€910k</td><td>€300k</td><td>€3.1M</td><td>Jonas Brandt</td>
126
+ </tr>
127
+ <tr>
128
+ <td>Nordics</td><td>€780k</td><td>€260k</td><td>€90k</td><td>€1.0M</td><td>Elin Karlsson</td>
129
+ </tr>
130
+ </table>
131
+ <p>Full detail in the attached sheet.</p>
132
+ </div>
133
+ `;
134
+
57
135
  const COLUMN: Decorator = (Story) => (
58
136
  <div style={{ width: 720 }}>
59
137
  <Story />
60
138
  </div>
61
139
  );
62
140
 
141
+ // A reading column on a fractional boundary — a flex pane at 720.5px, or any
142
+ // browser zoom off 100%. This is where a frame that took its width from its own
143
+ // content used to overflow its pane by a subpixel and grow a full-width scroll
144
+ // track under mail that plainly fits. The outline marks the column edge.
145
+ const FRACTIONAL_COLUMN: Decorator = (Story) => (
146
+ <div style={{ width: 720.5, outline: "1px dashed rgba(120,120,120,0.6)" }}>
147
+ <Story />
148
+ </div>
149
+ );
150
+
63
151
  const PHONE: Decorator = (Story) => (
64
152
  <div style={{ width: 390 }}>
65
153
  <Story />
66
154
  </div>
67
155
  );
68
156
 
157
+ // The message the pane puts the body under. Only the header reads it — the body
158
+ // is the story.
159
+ const PANE_MESSAGE = {
160
+ id: "pane-1",
161
+ fromName: "Ingrid Bakker",
162
+ fromEmail: "ingrid@example.com",
163
+ toLabel: "the choir list",
164
+ dateLabel: "Today, 20:04",
165
+ snippet: "De repetitie van donderdag gaat door.",
166
+ bodyHtml: "",
167
+ };
168
+
169
+ // The reading pane's own arrangement, composed from the component that owns it:
170
+ // the pane's canvas, the message gutter and the sandboxed frame running back out
171
+ // of that gutter. The story states the pane's width and nothing else — the
172
+ // inset, and the cancel that matches it, stay in `ExpandedMessage` where the app
173
+ // reads them from. Any seam between the email's ground and the pane around it
174
+ // shows up here.
175
+ const PANE: Decorator = (Story) => (
176
+ <div className="w-[720px] bg-canvas">
177
+ <ExpandedMessage message={PANE_MESSAGE} body={<Story />} />
178
+ </div>
179
+ );
180
+
69
181
  const meta: Meta<typeof MessageBodyView> = {
70
182
  title: "Components/MessageBodyView",
71
183
  component: MessageBodyView,
@@ -86,7 +198,8 @@ export const Newsletter: Story = {
86
198
  decorators: [COLUMN],
87
199
  };
88
200
 
89
- /** The same newsletter on a phone width — #727 scale-to-fit keeps it whole. */
201
+ /** The same newsletter on a phone width — it reflows to the frame, and what
202
+ * cannot reflow scrolls inside the frame's own document. */
90
203
  export const NewsletterMobile: Story = {
91
204
  args: { html: NODE_WEEKLY, category: "newsletter", allowImages: true },
92
205
  decorators: [PHONE],
@@ -139,6 +252,91 @@ export const ImagesLoaded: Story = {
139
252
  decorators: [COLUMN],
140
253
  };
141
254
 
255
+ /** A two-line note in a column whose width is not a whole pixel: the body must
256
+ * show no horizontal scrollbar, because nothing overflows. */
257
+ export const NarrowBodyNoScrollbar: Story = {
258
+ args: { html: SHORT_NOTE, category: "personal", allowImages: true },
259
+ decorators: [FRACTIONAL_COLUMN],
260
+ };
261
+
262
+ /** The same for a fixed-width newsletter that fills the column exactly — it
263
+ * fits, so the pane stays put. */
264
+ export const NewsletterFillsColumnNoScrollbar: Story = {
265
+ args: { html: NODE_WEEKLY, category: "newsletter", allowImages: true },
266
+ decorators: [FRACTIONAL_COLUMN],
267
+ };
268
+
269
+ /** A 1200px table that genuinely cannot fit: it scrolls horizontally inside the
270
+ * frame's own document, and neither the pane nor the page moves sideways. */
271
+ export const WideTableScrollsInPlace: Story = {
272
+ args: { html: WIDE_TABLE, category: "newsletter", allowImages: true },
273
+ decorators: [COLUMN],
274
+ };
275
+
276
+ /** The same wide table on a phone: the same in-document scroll, one behaviour
277
+ * at every width. */
278
+ export const WideTableMobile: Story = {
279
+ args: { html: WIDE_TABLE, category: "newsletter", allowImages: true },
280
+ decorators: [PHONE],
281
+ };
282
+
283
+ /** Formatted HTML mail that declares no background and no padding. The app
284
+ * supplies both: the ground is the pane's own colour, so there is no seam and
285
+ * no inner rectangle, and the breathing room sits inside that ground, so the
286
+ * text is inset while the surface still reaches both pane edges. */
287
+ export const UnstyledHtmlIsOneSurface: Story = {
288
+ args: { html: UNSTYLED_HTML, category: "personal", allowImages: true },
289
+ decorators: [PANE],
290
+ };
291
+
292
+ /** The same mail on the dark pane: the ground is still the pane's, never a
293
+ * lighter slab, and the inset still reads as margin rather than a colour
294
+ * change. */
295
+ export const UnstyledHtmlIsOneSurfaceDark: Story = {
296
+ args: {
297
+ html: UNSTYLED_HTML,
298
+ category: "personal",
299
+ allowImages: true,
300
+ isDark: true,
301
+ },
302
+ parameters: { theme: "dark" },
303
+ decorators: [PANE],
304
+ };
305
+
306
+ /** A newsletter that lays out its own container: it keeps its own background
307
+ * and its own 24px padding, and is given no second helping. */
308
+ export const SelfStyledNewsletterKeepsItsOwnPadding: Story = {
309
+ args: { html: NODE_WEEKLY, category: "newsletter", allowImages: true },
310
+ decorators: [PANE],
311
+ };
312
+
313
+ /** The same newsletter on the dark pane: its design is its own and is darkened
314
+ * as authored, not repainted. */
315
+ export const SelfStyledNewsletterKeepsItsOwnPaddingDark: Story = {
316
+ args: {
317
+ html: NODE_WEEKLY,
318
+ category: "newsletter",
319
+ allowImages: true,
320
+ isDark: true,
321
+ },
322
+ parameters: { theme: "dark" },
323
+ decorators: [PANE],
324
+ };
325
+
326
+ /** A plain-text message. There is no email document to run flush, so it keeps
327
+ * the message gutter and stays readable against the pane edge. */
328
+ export const PlainTextKeepsTheGutter: Story = {
329
+ args: { text: PLAIN_TEXT },
330
+ decorators: [PANE],
331
+ };
332
+
333
+ /** Author `nowrap` on flowing text: the clamp wraps it to the frame instead of
334
+ * pinning a line the frame can only cut. */
335
+ export const NowrapTextStillWraps: Story = {
336
+ args: { html: UNSTYLED_HTML_NOWRAP, category: "personal", allowImages: true },
337
+ decorators: [PANE],
338
+ };
339
+
142
340
  /** No body content: the empty-state fallback. */
143
341
  export const Empty: Story = {
144
342
  args: { html: undefined, text: undefined },