@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,9 +1,12 @@
1
1
  import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, waitFor } from "storybook/test";
3
+ import { generateLayoutClampCSS } from "../lib/email-layout-clamp.js";
2
4
  import { IsolatedEmailFrame } from "./isolated-email-frame.js";
3
5
 
4
6
  /**
5
- * `IsolatedEmailFrame` renders sanitized email HTML in a sandboxed iframe that
6
- * fits the viewport width on mobile and isolates the email's CSS from the app.
7
+ * `IsolatedEmailFrame` renders sanitized email HTML in a sandboxed iframe that is
8
+ * exactly as wide as the box holding it and isolates the email's CSS from the
9
+ * app.
7
10
  *
8
11
  * The component receives HTML that already carries the sanitizer's layout-clamp
9
12
  * `<style>` block, so these stories prepend the same clamp CSS to each fixture
@@ -12,28 +15,18 @@ import { IsolatedEmailFrame } from "./isolated-email-frame.js";
12
15
  * overflowed a phone — rendered at a phone width and at a desktop
13
16
  * reading-column width.
14
17
  *
15
- * On a phone (window ≤640px) a fixed-width email whose inline `min-width` beats
16
- * the clamp can't reflow; the frame renders it at its natural width and
17
- * CSS-scales the whole iframe down so it fits the container WHOLE rather than
18
- * being clipped (#727). To see the scale in this Storybook, narrow the browser
19
- * window to a phone width — the fit-to-width decision reads the window media
20
- * query, not the fixed-width decorator. The `computeFitScale` unit test pins the
21
- * scaling policy independently of the viewport.
18
+ * The app's layout never reacts to what is inside the frame. Mail that can
19
+ * reflow does, against the pane's width; mail that genuinely cannot a table
20
+ * with its own `min-width`, an image the same, a `pre` the author pinned
21
+ * scrolls inside the document, and neither the pane nor the page moves sideways
22
+ * at any width.
22
23
  */
23
24
 
24
- // Mirror of the sanitizer's `generateLayoutClampCSS` — clamps wide author
25
- // markup (fixed-width tables/cells, oversized media) to the frame width and
26
- // wraps long unbroken tokens. In the app this is prepended by the sanitizer;
27
- // here we prepend it so the fixtures exercise the same reflow.
28
- const LAYOUT_CLAMP_CSS = `<style>
29
- html, body { margin: 0; padding: 0; max-width: 100%; }
30
- body { overflow-wrap: anywhere; word-break: break-word; }
31
- img, video, iframe, svg, canvas { max-width: 100% !important; height: auto; }
32
- table { max-width: 100% !important; table-layout: auto; }
33
- td, th { max-width: 100% !important; }
34
- * { min-width: 0; }
35
- pre, code { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; }
36
- </style>`;
25
+ // The sanitizer's own clamp — clamps wide author markup (fixed-width
26
+ // tables/cells, oversized media) to the frame width and wraps long unbroken
27
+ // tokens. In the app the sanitizer prepends it; here the fixtures prepend the
28
+ // same generated stylesheet, so a rule that changes there changes here.
29
+ const LAYOUT_CLAMP_CSS = `<style>${generateLayoutClampCSS()}</style>`;
37
30
 
38
31
  const HERO = `data:image/svg+xml;utf8,${encodeURIComponent(
39
32
  `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="200" viewBox="0 0 600 200">
@@ -108,18 +101,109 @@ const PLAIN = `${LAYOUT_CLAMP_CSS}
108
101
  </div>
109
102
  `;
110
103
 
104
+ // Mail that genuinely cannot fit: an inline `min-width` on the table outranks
105
+ // the clamp's `* { min-width: 0 }`, so 1200px of columns stay 1200px wide and
106
+ // the email's own document is what the reader scrolls to see the rest.
107
+ const WIDE_TABLE = `${LAYOUT_CLAMP_CSS}
108
+ <div style="font-family: Helvetica, Arial, sans-serif; color:#1a1a1a;">
109
+ <h1 style="font-size:20px;margin:0 0 12px;">Q2 regional breakdown</h1>
110
+ <table cellpadding="8" cellspacing="0" style="min-width:1200px;border-collapse:collapse;">
111
+ <tr style="background:#efefef;">
112
+ <th style="min-width:200px;text-align:left;">Region</th>
113
+ <th style="min-width:200px;text-align:left;">Pipeline</th>
114
+ <th style="min-width:200px;text-align:left;">Closed won</th>
115
+ <th style="min-width:200px;text-align:left;">Closed lost</th>
116
+ <th style="min-width:200px;text-align:left;">Forecast</th>
117
+ <th style="min-width:200px;text-align:left;">Owner</th>
118
+ </tr>
119
+ <tr>
120
+ <td>Benelux</td><td>&euro;1.2M</td><td>&euro;480k</td><td>&euro;120k</td><td>&euro;1.6M</td><td>Sanne de Vries</td>
121
+ </tr>
122
+ <tr style="background:#f8f8f8;">
123
+ <td>DACH</td><td>&euro;2.4M</td><td>&euro;910k</td><td>&euro;300k</td><td>&euro;3.1M</td><td>Jonas Brandt</td>
124
+ </tr>
125
+ </table>
126
+ <p>Full detail in the attached sheet.</p>
127
+ </div>
128
+ `;
129
+
130
+ // Mail that declares nothing: no background, no padding, no width. The frame
131
+ // supplies the ground — the reading pane's own colour — and the breathing room
132
+ // inside it, so the surface reaches both pane edges and only the text is inset.
133
+ const BARE_MAIL = `${LAYOUT_CLAMP_CSS}
134
+ <div>
135
+ <p>Hoi allemaal,</p>
136
+ <p>De repetitie van donderdag gaat door. We beginnen met het nieuwe stuk en
137
+ repeteren om 20.00 uur verder aan het programma voor het najaarsconcert.</p>
138
+ <p>Groeten,<br>Ingrid</p>
139
+ </div>
140
+ `;
141
+
142
+ // The same mail with an author `nowrap`: the clamp wraps it rather than leaving
143
+ // a paragraph that reads only by dragging the email sideways.
144
+ const NOWRAP_MAIL = `${LAYOUT_CLAMP_CSS}
145
+ <div style="white-space:nowrap">
146
+ <p>Hoi allemaal,</p>
147
+ <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>
148
+ <p>Groeten,<br>Ingrid</p>
149
+ </div>
150
+ `;
151
+
152
+ // The same nowrap as Outlook and the older generators emit it: uppercase, in an
153
+ // uppercase attribute. A case-sensitive attribute-value match sails past this
154
+ // one and pins the line the lowercase twin wraps.
155
+ const SHOUTED_NOWRAP_MAIL = `${LAYOUT_CLAMP_CSS}
156
+ <div STYLE="WHITE-SPACE: NOWRAP" id="shouted">
157
+ <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>
158
+ </div>
159
+ `;
160
+
161
+ // A code block with a nowrap span inside it. Unwrapping that span to `normal`
162
+ // collapses the runs of spaces that are the entire point of a `<pre>`, so the
163
+ // clamp has to wrap it instead of flattening it.
164
+ const PREFORMATTED_MAIL = `${LAYOUT_CLAMP_CSS}
165
+ <pre id="listing">outer <span style="white-space:nowrap" id="inner">A B</span></pre>
166
+ `;
167
+
168
+ // The reading pane's ground behind the frame, so a seam between the two shows.
169
+ const PANE: Decorator = (Story) => (
170
+ <div className="overflow-x-auto bg-canvas" style={{ width: 720 }}>
171
+ <Story />
172
+ </div>
173
+ );
174
+
111
175
  const PHONE: Decorator = (Story) => (
112
176
  <div className="overflow-x-auto" style={{ width: 390 }}>
113
177
  <Story />
114
178
  </div>
115
179
  );
116
180
 
181
+ // A container on a fractional boundary — a flex reading pane at 720.5px, or any
182
+ // browser zoom off 100%. The DOM rounds every width measurement to a whole
183
+ // pixel, so a frame pinned to its measured content width used to overflow this
184
+ // container by a subpixel and grow a scroll track under mail that fits.
185
+ const FRACTIONAL_COLUMN: Decorator = (Story) => (
186
+ <div
187
+ className="overflow-x-auto"
188
+ style={{ width: 720.5, outline: "1px dashed rgba(120,120,120,0.6)" }}
189
+ >
190
+ <Story />
191
+ </div>
192
+ );
193
+
117
194
  const COLUMN: Decorator = (Story) => (
118
195
  <div className="overflow-x-auto" style={{ width: 720 }}>
119
196
  <Story />
120
197
  </div>
121
198
  );
122
199
 
200
+ // What the sanitizer reports for these fixtures. Designed mail brings its own
201
+ // ground and its own container padding, so the frame stands back on both; the
202
+ // bare note brings neither, so the frame supplies the pane's ground and the
203
+ // breathing room inside it.
204
+ const DESIGNED = { background: true, spacing: true };
205
+ const BARE = { background: false, spacing: false };
206
+
123
207
  const meta: Meta<typeof IsolatedEmailFrame> = {
124
208
  title: "Components/IsolatedEmailFrame",
125
209
  component: IsolatedEmailFrame,
@@ -135,43 +219,68 @@ type Story = StoryObj<typeof IsolatedEmailFrame>;
135
219
 
136
220
  /** #727: a 600px fixed-width Node Weekly table at a 390px phone width. The inline
137
221
  * `min-width:600px` on the `<td>` beats the clamp so the table can't collapse;
138
- * the frame scales the whole email down to fit the box WHOLE, with no clipping
139
- * and no horizontal page scroll. */
222
+ * it scrolls inside the document instead, and the phone-width box around it
223
+ * stays put. */
140
224
  export const NodeWeeklyMobile: Story = {
141
- args: { html: NODE_WEEKLY, variant: "framed", isDark: false },
225
+ args: {
226
+ html: NODE_WEEKLY,
227
+ variant: "framed",
228
+ isDark: false,
229
+ declares: DESIGNED,
230
+ },
142
231
  decorators: [PHONE],
143
232
  };
144
233
 
145
234
  /** The same Node Weekly newsletter on a desktop reading column. */
146
235
  export const NodeWeeklyDesktop: Story = {
147
- args: { html: NODE_WEEKLY, variant: "framed", isDark: false },
236
+ args: {
237
+ html: NODE_WEEKLY,
238
+ variant: "framed",
239
+ isDark: false,
240
+ declares: DESIGNED,
241
+ },
148
242
  decorators: [COLUMN],
149
243
  };
150
244
 
151
245
  /** Gaslicht.com-style 600px fixed-width marketing mail at phone width: the hero
152
- * image and CTA scale down with the frame to fit the phone whole. */
246
+ * image reflows to the frame and the fixed body scrolls inside the document. */
153
247
  export const GaslichtMobile: Story = {
154
- args: { html: GASLICHT, variant: "framed", isDark: false },
248
+ args: {
249
+ html: GASLICHT,
250
+ variant: "framed",
251
+ isDark: false,
252
+ declares: DESIGNED,
253
+ },
155
254
  decorators: [PHONE],
156
255
  };
157
256
 
158
257
  /** Substack-style fluid newsletter on a desktop column: fills the reading
159
258
  * width via the framed `max(100%, content)` path. */
160
259
  export const SubstackDesktop: Story = {
161
- args: { html: SUBSTACK, variant: "framed", isDark: false },
260
+ args: {
261
+ html: SUBSTACK,
262
+ variant: "framed",
263
+ isDark: false,
264
+ declares: DESIGNED,
265
+ },
162
266
  decorators: [COLUMN],
163
267
  };
164
268
 
165
269
  /** Substack fluid newsletter reflowed to a phone width. */
166
270
  export const SubstackMobile: Story = {
167
- args: { html: SUBSTACK, variant: "framed", isDark: false },
271
+ args: {
272
+ html: SUBSTACK,
273
+ variant: "framed",
274
+ isDark: false,
275
+ declares: DESIGNED,
276
+ },
168
277
  decorators: [PHONE],
169
278
  };
170
279
 
171
280
  /** Framed newsletter on the DARK reading pane: smart-inverted to charcoal with
172
281
  * the hero re-inverted back to natural color. */
173
282
  export const NewsletterDarkPane: Story = {
174
- args: { html: GASLICHT, variant: "framed", isDark: true },
283
+ args: { html: GASLICHT, variant: "framed", isDark: true, declares: DESIGNED },
175
284
  parameters: { theme: "dark" },
176
285
  decorators: [COLUMN],
177
286
  };
@@ -179,14 +288,390 @@ export const NewsletterDarkPane: Story = {
179
288
  /** Plain personal email: UI font-stack + theme-aware colors injected so the
180
289
  * black-on-white author text stays readable in either theme. */
181
290
  export const PlainEmail: Story = {
182
- args: { html: PLAIN, variant: "plain", isDark: false },
291
+ args: { html: PLAIN, variant: "plain", isDark: false, declares: BARE },
183
292
  decorators: [COLUMN],
184
293
  };
185
294
 
186
295
  /** Plain email in dark mode: must be light text on the dark surface, never
187
296
  * black-on-dark. */
188
297
  export const PlainEmailDark: Story = {
189
- args: { html: PLAIN, variant: "plain", isDark: true },
298
+ args: { html: PLAIN, variant: "plain", isDark: true, declares: BARE },
190
299
  parameters: { theme: "dark" },
191
300
  decorators: [COLUMN],
192
301
  };
302
+
303
+ /** A short plain email in a fractional-width column: nothing overflows, so no
304
+ * horizontal scrollbar may appear under it. */
305
+ export const FitsFractionalColumn: Story = {
306
+ args: { html: PLAIN, variant: "plain", isDark: false, declares: BARE },
307
+ decorators: [FRACTIONAL_COLUMN],
308
+ };
309
+
310
+ /** A fluid newsletter filling the same fractional column: still no scrollbar. */
311
+ export const SubstackFractionalColumn: Story = {
312
+ args: {
313
+ html: SUBSTACK,
314
+ variant: "framed",
315
+ isDark: false,
316
+ declares: DESIGNED,
317
+ },
318
+ decorators: [FRACTIONAL_COLUMN],
319
+ };
320
+
321
+ /** A 1200px table that genuinely does not fit: it scrolls inside the document,
322
+ * and the column holding the frame never grows a scrollbar of its own. */
323
+ export const WideTableScrollsInPlace: Story = {
324
+ args: {
325
+ html: WIDE_TABLE,
326
+ variant: "framed",
327
+ isDark: false,
328
+ declares: DESIGNED,
329
+ },
330
+ decorators: [COLUMN],
331
+ };
332
+
333
+ /** The same table on a phone: the same in-document scroll, one behaviour at
334
+ * every width. */
335
+ export const WideTableMobile: Story = {
336
+ args: {
337
+ html: WIDE_TABLE,
338
+ variant: "framed",
339
+ isDark: false,
340
+ declares: DESIGNED,
341
+ },
342
+ decorators: [PHONE],
343
+ };
344
+
345
+ /** Mail that brings no ground of its own: the frame's is the pane's, so there
346
+ * is no seam and no inner rectangle, and the injected inset reads as margin
347
+ * rather than a colour change. */
348
+ export const BareMailIsOneSurfaceWithThePane: Story = {
349
+ args: { html: BARE_MAIL, variant: "framed", isDark: false, declares: BARE },
350
+ decorators: [PANE],
351
+ };
352
+
353
+ /** The same on the dark pane, where an app-supplied white canvas used to be
354
+ * inverted into a charcoal slab sitting inside the pane. */
355
+ export const BareMailIsOneSurfaceWithThePaneDark: Story = {
356
+ args: { html: BARE_MAIL, variant: "framed", isDark: true, declares: BARE },
357
+ parameters: { theme: "dark" },
358
+ decorators: [PANE],
359
+ };
360
+
361
+ /** Author `nowrap` on flowing text: wrapped to the frame, never cut. */
362
+ export const NowrapTextStillWraps: Story = {
363
+ args: { html: NOWRAP_MAIL, variant: "framed", isDark: false, declares: BARE },
364
+ decorators: [PANE],
365
+ };
366
+
367
+ // An element of the sanitized mail, once the frame has parsed its srcDoc — the
368
+ // story's first paint does not wait for that.
369
+ const frameElement = async (canvasElement: HTMLElement, id: string) => {
370
+ const iframe = canvasElement.querySelector("iframe");
371
+ if (!iframe) throw new Error("no email frame in the story");
372
+ return await waitFor(() => {
373
+ const element = iframe.contentDocument?.getElementById(id);
374
+ if (!element) throw new Error(`the frame has not rendered #${id} yet`);
375
+ return element;
376
+ });
377
+ };
378
+
379
+ const computedWhiteSpace = (element: Element): string =>
380
+ element.ownerDocument.defaultView?.getComputedStyle(element).whiteSpace ?? "";
381
+
382
+ /** The same nowrap in the case Outlook writes it. An attribute-value match is
383
+ * case-sensitive by default, so this line stayed pinned while its lowercase
384
+ * twin wrapped. */
385
+ export const ShoutedNowrapStillWraps: Story = {
386
+ args: {
387
+ html: SHOUTED_NOWRAP_MAIL,
388
+ variant: "framed",
389
+ isDark: false,
390
+ declares: BARE,
391
+ },
392
+ decorators: [PHONE],
393
+ play: async ({ canvasElement }) => {
394
+ const shouted = await frameElement(canvasElement, "shouted");
395
+ await expect(computedWhiteSpace(shouted)).toBe("normal");
396
+ },
397
+ };
398
+
399
+ /** A `<pre>` keeps its spacing. Unwrapping a nowrap span inside it to `normal`
400
+ * collapses the runs of spaces the block exists to preserve, so the clamp
401
+ * wraps it to `pre-wrap` rather than flattening it. */
402
+ export const PreformattedTextKeepsItsSpacing: Story = {
403
+ args: {
404
+ html: PREFORMATTED_MAIL,
405
+ variant: "framed",
406
+ isDark: false,
407
+ declares: BARE,
408
+ },
409
+ decorators: [PANE],
410
+ play: async ({ canvasElement }) => {
411
+ const inner = await frameElement(canvasElement, "inner");
412
+ const doc = inner.ownerDocument;
413
+ await expect(computedWhiteSpace(inner)).toBe("pre-wrap");
414
+ // Computed style is the rule; the rendered width is the spaces surviving.
415
+ // The twin sits in the same block so it inherits the same monospace font
416
+ // and the only difference between the two is the whitespace handling.
417
+ const collapsed = doc.createElement("span");
418
+ collapsed.style.whiteSpace = "normal";
419
+ collapsed.textContent = inner.textContent;
420
+ inner.parentElement?.appendChild(collapsed);
421
+ await expect(inner.getBoundingClientRect().width).toBeGreaterThan(
422
+ collapsed.getBoundingClientRect().width,
423
+ );
424
+ },
425
+ };
426
+
427
+ /* ------------------------------------------------------------------ */
428
+ /* Overflow belongs to the document, not to the app */
429
+ /* ------------------------------------------------------------------ */
430
+
431
+ // An image with a min-width of its own: the clamp's `max-width:100% !important`
432
+ // caps it, and the un-important `* { min-width: 0 }` loses to the inline style,
433
+ // so the picture stays 1400px however narrow the pane is.
434
+ const OVERSIZED_IMAGE = `${LAYOUT_CLAMP_CSS}
435
+ <div style="font-family: Helvetica, Arial, sans-serif; color:#1a1a1a;">
436
+ <h1 style="font-size:20px;margin:0 0 12px;">Site plan, revision C</h1>
437
+ <img src="${HERO}" alt="Site plan" width="1400" style="min-width:1400px;height:auto;" />
438
+ <p>The lot boundary moved two metres north.</p>
439
+ </div>
440
+ `;
441
+
442
+ // A code listing the author pinned to `white-space: pre`: rewrapping it would
443
+ // change what it says, so it stays as wide as its longest line.
444
+ const PINNED_PRE = `${LAYOUT_CLAMP_CSS}
445
+ <div style="font-family: Helvetica, Arial, sans-serif; color:#1a1a1a;">
446
+ <p>The failing command, verbatim:</p>
447
+ <pre style="white-space:pre">rsync --archive --compress --delete --exclude node_modules --exclude .git ./packages/backend deploy@build-01.internal:/srv/releases/2026-08-11
448
+ rsync error: some files could not be transferred (code 23) at main.c(1338)</pre>
449
+ </div>
450
+ `;
451
+
452
+ // A pane with nothing to catch an overflow: no `overflow-x`, so content that
453
+ // escapes the frame shows up as a scrollbar on the pane or on the page, and the
454
+ // assertions below see it.
455
+ const BARE_PANE: Decorator = (Story) => (
456
+ <div data-pane className="bg-canvas" style={{ width: 720 }}>
457
+ <Story />
458
+ </div>
459
+ );
460
+
461
+ const BARE_PHONE: Decorator = (Story) => (
462
+ <div data-pane className="bg-canvas" style={{ width: 390 }}>
463
+ <Story />
464
+ </div>
465
+ );
466
+
467
+ /**
468
+ * Content that genuinely cannot wrap scrolls where it lives — inside the
469
+ * document — and nothing outside the frame moves sideways for it. The frame is
470
+ * the pane's width and the pane has nothing to scroll, so there is no route for
471
+ * the overflow to reach the page either.
472
+ */
473
+ const assertScrollsInsideTheDocument = async (canvasElement: HTMLElement) => {
474
+ const iframe = canvasElement.querySelector("iframe");
475
+ if (!iframe) throw new Error("no email frame in the story");
476
+ await waitFor(() => {
477
+ const body = iframe.contentDocument?.body;
478
+ if (!body) throw new Error("the frame has not parsed its document yet");
479
+ if (body.scrollWidth <= body.clientWidth) {
480
+ throw new Error("the document is not holding its own overflow yet");
481
+ }
482
+ });
483
+ const pane = canvasElement.querySelector<HTMLElement>("[data-pane]");
484
+ if (!pane) throw new Error("no pane in the story");
485
+ await expect(iframe.getBoundingClientRect().width).toBeLessThanOrEqual(
486
+ pane.clientWidth,
487
+ );
488
+ await expect(pane.scrollWidth).toBeLessThanOrEqual(pane.clientWidth);
489
+ };
490
+
491
+ /** A 1200px table on a desktop reading column. */
492
+ export const WideTableScrollsInsideTheDocument: Story = {
493
+ args: {
494
+ html: WIDE_TABLE,
495
+ variant: "framed",
496
+ isDark: false,
497
+ declares: DESIGNED,
498
+ },
499
+ decorators: [BARE_PANE],
500
+ play: async ({ canvasElement }) => {
501
+ await assertScrollsInsideTheDocument(canvasElement);
502
+ },
503
+ };
504
+
505
+ /** The same table on a phone. */
506
+ export const WideTableScrollsInsideTheDocumentOnAPhone: Story = {
507
+ args: {
508
+ html: WIDE_TABLE,
509
+ variant: "framed",
510
+ isDark: false,
511
+ declares: DESIGNED,
512
+ },
513
+ decorators: [BARE_PHONE],
514
+ play: async ({ canvasElement }) => {
515
+ await assertScrollsInsideTheDocument(canvasElement);
516
+ },
517
+ };
518
+
519
+ /** A 1400px image on a desktop reading column. */
520
+ export const OversizedImageScrollsInsideTheDocument: Story = {
521
+ args: {
522
+ html: OVERSIZED_IMAGE,
523
+ variant: "framed",
524
+ isDark: false,
525
+ declares: BARE,
526
+ },
527
+ decorators: [BARE_PANE],
528
+ play: async ({ canvasElement }) => {
529
+ await assertScrollsInsideTheDocument(canvasElement);
530
+ },
531
+ };
532
+
533
+ /** The same image on a phone. */
534
+ export const OversizedImageScrollsInsideTheDocumentOnAPhone: Story = {
535
+ args: {
536
+ html: OVERSIZED_IMAGE,
537
+ variant: "framed",
538
+ isDark: false,
539
+ declares: BARE,
540
+ },
541
+ decorators: [BARE_PHONE],
542
+ play: async ({ canvasElement }) => {
543
+ await assertScrollsInsideTheDocument(canvasElement);
544
+ },
545
+ };
546
+
547
+ /** A pinned code listing on a desktop reading column. */
548
+ export const PinnedPreScrollsInsideTheDocument: Story = {
549
+ args: {
550
+ html: PINNED_PRE,
551
+ variant: "framed",
552
+ isDark: false,
553
+ declares: BARE,
554
+ },
555
+ decorators: [BARE_PANE],
556
+ play: async ({ canvasElement }) => {
557
+ await assertScrollsInsideTheDocument(canvasElement);
558
+ },
559
+ };
560
+
561
+ /** The same listing on a phone. */
562
+ export const PinnedPreScrollsInsideTheDocumentOnAPhone: Story = {
563
+ args: {
564
+ html: PINNED_PRE,
565
+ variant: "framed",
566
+ isDark: false,
567
+ declares: BARE,
568
+ },
569
+ decorators: [BARE_PHONE],
570
+ play: async ({ canvasElement }) => {
571
+ await assertScrollsInsideTheDocument(canvasElement);
572
+ },
573
+ };
574
+
575
+ /* ------------------------------------------------------------------ */
576
+ /* The frame is the column, whatever the mail is */
577
+ /* ------------------------------------------------------------------ */
578
+
579
+ /**
580
+ * Two columns of the same width, a two-line note in one and 1200px of table in
581
+ * the other. Both frames are their column, exactly — the width the reader sees
582
+ * comes from the app's layout and nothing about the mail can move it. The old
583
+ * policy measured the content and sized the frame to it, which is what put a
584
+ * scroll track under mail that fitted.
585
+ */
586
+ const assertBothFramesAreTheirColumn = async (canvasElement: HTMLElement) => {
587
+ const panes = [...canvasElement.querySelectorAll<HTMLElement>("[data-pane]")];
588
+ await expect(panes.length).toBe(2);
589
+ for (const pane of panes) {
590
+ const iframe = pane.querySelector("iframe");
591
+ if (!iframe) throw new Error("no email frame in the pane");
592
+ await waitFor(() => {
593
+ if (!iframe.contentDocument?.body) {
594
+ throw new Error("the frame has not parsed its document yet");
595
+ }
596
+ });
597
+ await expect(
598
+ Math.abs(iframe.getBoundingClientRect().width - pane.clientWidth),
599
+ ).toBeLessThanOrEqual(1);
600
+ await expect(pane.scrollWidth).toBeLessThanOrEqual(pane.clientWidth);
601
+ }
602
+ };
603
+
604
+ const twoColumns = (width: number) => (
605
+ <div className="flex flex-col gap-4">
606
+ <div data-pane className="bg-canvas" style={{ width }}>
607
+ <IsolatedEmailFrame
608
+ html={BARE_MAIL}
609
+ variant="framed"
610
+ isDark={false}
611
+ declares={BARE}
612
+ />
613
+ </div>
614
+ <div data-pane className="bg-canvas" style={{ width }}>
615
+ <IsolatedEmailFrame
616
+ html={WIDE_TABLE}
617
+ variant="framed"
618
+ isDark={false}
619
+ declares={DESIGNED}
620
+ />
621
+ </div>
622
+ </div>
623
+ );
624
+
625
+ /** A desktop reading column. */
626
+ export const FrameIsTheColumnWhateverTheMail: Story = {
627
+ render: () => twoColumns(720),
628
+ play: async ({ canvasElement }) => {
629
+ await assertBothFramesAreTheirColumn(canvasElement);
630
+ },
631
+ };
632
+
633
+ /** The same pair on a phone, where the table is three times the column. */
634
+ export const FrameIsTheColumnWhateverTheMailOnAPhone: Story = {
635
+ render: () => twoColumns(390),
636
+ play: async ({ canvasElement }) => {
637
+ await assertBothFramesAreTheirColumn(canvasElement);
638
+ },
639
+ };
640
+
641
+ // A pane as wide as the page, with nothing to catch an overflow: an email that
642
+ // escaped its frame has nowhere to go but the document itself, where a
643
+ // horizontal scrollbar under the whole app is what the reader would see.
644
+ const PAGE_WIDE_PANE: Decorator = (Story) => (
645
+ <div data-pane className="w-full bg-canvas">
646
+ <Story />
647
+ </div>
648
+ );
649
+
650
+ /**
651
+ * A 1400px image in a pane the width of the page. The mail scrolls where it
652
+ * lives and the page holds still — the app never learns how wide the picture
653
+ * was.
654
+ */
655
+ export const OversizedImageLeavesThePageStill: Story = {
656
+ args: {
657
+ html: OVERSIZED_IMAGE,
658
+ variant: "framed",
659
+ isDark: false,
660
+ declares: BARE,
661
+ },
662
+ decorators: [PAGE_WIDE_PANE],
663
+ play: async ({ canvasElement }) => {
664
+ const pane = canvasElement.querySelector<HTMLElement>("[data-pane]");
665
+ if (!pane) throw new Error("no pane in the story");
666
+ const iframe = pane.querySelector("iframe");
667
+ if (!iframe) throw new Error("no email frame in the pane");
668
+ await waitFor(() => {
669
+ if (!iframe.contentDocument?.body) {
670
+ throw new Error("the frame has not parsed its document yet");
671
+ }
672
+ });
673
+ await expect(pane.scrollWidth).toBeLessThanOrEqual(pane.clientWidth);
674
+ const page = canvasElement.ownerDocument.documentElement;
675
+ await expect(page.scrollWidth).toBeLessThanOrEqual(page.clientWidth);
676
+ },
677
+ };