@remit/ui 0.0.1
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 +50 -0
- package/src/components/address-display.render.test.ts +93 -0
- package/src/components/address-display.stories.tsx +55 -0
- package/src/components/address-display.tsx +132 -0
- package/src/components/address-tag.render.test.ts +39 -0
- package/src/components/address-tag.stories.tsx +41 -0
- package/src/components/address-tag.tsx +38 -0
- package/src/components/app-password-hint.render.test.ts +23 -0
- package/src/components/app-password-hint.stories.tsx +19 -0
- package/src/components/app-password-hint.tsx +24 -0
- package/src/components/app-shell-slotted.render.test.ts +128 -0
- package/src/components/app-shell-slotted.tsx +267 -0
- package/src/components/app-shell-types.ts +328 -0
- package/src/components/app-shell.render.test.ts +415 -0
- package/src/components/app-shell.tsx +277 -0
- package/src/components/auth-card.render.test.ts +24 -0
- package/src/components/auth-card.stories.tsx +50 -0
- package/src/components/auth-card.tsx +32 -0
- package/src/components/auth-footer.render.test.ts +17 -0
- package/src/components/auth-footer.stories.tsx +23 -0
- package/src/components/auth-footer.tsx +14 -0
- package/src/components/auth-hero.render.test.ts +31 -0
- package/src/components/auth-hero.stories.tsx +19 -0
- package/src/components/auth-hero.tsx +41 -0
- package/src/components/auto-moved-badge.render.test.ts +42 -0
- package/src/components/auto-moved-badge.stories.tsx +48 -0
- package/src/components/auto-moved-badge.tsx +57 -0
- package/src/components/avatar.tsx +60 -0
- package/src/components/badge.tsx +49 -0
- package/src/components/banner.render.test.ts +58 -0
- package/src/components/banner.stories.tsx +92 -0
- package/src/components/banner.tsx +81 -0
- package/src/components/bottom-sheet.render.test.ts +48 -0
- package/src/components/bottom-sheet.stories.tsx +71 -0
- package/src/components/bottom-sheet.tsx +146 -0
- package/src/components/brief-section.render.test.ts +98 -0
- package/src/components/brief-section.stories.tsx +68 -0
- package/src/components/brief-section.tsx +102 -0
- package/src/components/brief-sections.render.test.ts +72 -0
- package/src/components/brief-sections.stories.tsx +178 -0
- package/src/components/brief-sections.tsx +201 -0
- package/src/components/button.tsx +48 -0
- package/src/components/card.tsx +48 -0
- package/src/components/category-badge.render.test.ts +40 -0
- package/src/components/category-badge.stories.tsx +37 -0
- package/src/components/category-badge.tsx +71 -0
- package/src/components/checkbox.render.test.ts +65 -0
- package/src/components/checkbox.stories.tsx +77 -0
- package/src/components/checkbox.tsx +78 -0
- package/src/components/compose-action-bar.render.test.ts +47 -0
- package/src/components/compose-action-bar.stories.tsx +35 -0
- package/src/components/compose-action-bar.tsx +95 -0
- package/src/components/compose-form-shell.render.test.ts +54 -0
- package/src/components/compose-form-shell.stories.tsx +96 -0
- package/src/components/compose-form-shell.tsx +49 -0
- package/src/components/danger-zone-section.tsx +36 -0
- package/src/components/dialog.render.test.ts +55 -0
- package/src/components/dialog.tsx +104 -0
- package/src/components/email-frame-css.ts +137 -0
- package/src/components/field-label.render.test.ts +16 -0
- package/src/components/field-label.tsx +19 -0
- package/src/components/filter-sheet.render.test.ts +76 -0
- package/src/components/filter-sheet.stories.tsx +146 -0
- package/src/components/filter-sheet.tsx +316 -0
- package/src/components/folder-role.tsx +66 -0
- package/src/components/input.tsx +44 -0
- package/src/components/intelligence-panel.stories.tsx +149 -0
- package/src/components/intelligence-panel.tsx +546 -0
- package/src/components/isolated-email-frame.render.test.ts +147 -0
- package/src/components/isolated-email-frame.stories.tsx +192 -0
- package/src/components/isolated-email-frame.tsx +265 -0
- package/src/components/kbd.tsx +17 -0
- package/src/components/keyboard-hint-bar.tsx +58 -0
- package/src/components/list-item.tsx +39 -0
- package/src/components/mail-action-toolbar.render.test.ts +43 -0
- package/src/components/mail-action-toolbar.tsx +180 -0
- package/src/components/mail-header.render.test.ts +69 -0
- package/src/components/mail-header.stories.tsx +233 -0
- package/src/components/mail-header.tsx +123 -0
- package/src/components/message-body-view.render.test.ts +59 -0
- package/src/components/message-body-view.stories.tsx +146 -0
- package/src/components/message-body-view.tsx +150 -0
- package/src/components/message-header.render.test.ts +58 -0
- package/src/components/message-header.stories.tsx +79 -0
- package/src/components/message-header.tsx +59 -0
- package/src/components/message-list-pane.render.test.ts +136 -0
- package/src/components/message-list-pane.stories.tsx +246 -0
- package/src/components/message-list-pane.tsx +248 -0
- package/src/components/message-list-state.render.test.ts +72 -0
- package/src/components/message-list-state.tsx +97 -0
- package/src/components/message-row.render.test.ts +48 -0
- package/src/components/message-row.stories.tsx +121 -0
- package/src/components/message-row.tsx +231 -0
- package/src/components/mobile-message-action-bar.render.test.ts +87 -0
- package/src/components/mobile-message-action-bar.stories.tsx +57 -0
- package/src/components/mobile-message-action-bar.tsx +175 -0
- package/src/components/mobile-reading-pane.render.test.ts +102 -0
- package/src/components/mobile-reading-pane.tsx +188 -0
- package/src/components/mobile-search-view.stories.tsx +247 -0
- package/src/components/mobile-search-view.tsx +100 -0
- package/src/components/move-mailbox-picker.render.test.ts +113 -0
- package/src/components/move-mailbox-picker.stories.tsx +76 -0
- package/src/components/move-mailbox-picker.tsx +324 -0
- package/src/components/nav-sidebar.render.test.ts +351 -0
- package/src/components/nav-sidebar.stories.tsx +216 -0
- package/src/components/nav-sidebar.tsx +625 -0
- package/src/components/outbox-row.render.test.ts +61 -0
- package/src/components/outbox-row.stories.tsx +53 -0
- package/src/components/outbox-row.tsx +120 -0
- package/src/components/outbox-status-badge.tsx +60 -0
- package/src/components/pane-layout.render.test.ts +105 -0
- package/src/components/popover-menu.render.test.ts +32 -0
- package/src/components/popover-menu.stories.tsx +57 -0
- package/src/components/popover-menu.tsx +110 -0
- package/src/components/primitives.stories.tsx +173 -0
- package/src/components/pull-to-refresh.render.test.ts +46 -0
- package/src/components/pull-to-refresh.stories.tsx +71 -0
- package/src/components/pull-to-refresh.tsx +54 -0
- package/src/components/quoted-text.render.test.ts +39 -0
- package/src/components/quoted-text.stories.tsx +67 -0
- package/src/components/quoted-text.tsx +67 -0
- package/src/components/reading-pane-empty.tsx +44 -0
- package/src/components/reading-pane.render.test.ts +148 -0
- package/src/components/reading-pane.stories.tsx +194 -0
- package/src/components/reading-pane.tsx +351 -0
- package/src/components/rescue-banner.render.test.ts +32 -0
- package/src/components/rescue-banner.stories.tsx +31 -0
- package/src/components/rescue-banner.tsx +50 -0
- package/src/components/rescue-candidate-row.render.test.ts +68 -0
- package/src/components/rescue-candidate-row.stories.tsx +108 -0
- package/src/components/rescue-candidate-row.tsx +94 -0
- package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
- package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
- package/src/components/rescue-from-spam-flow.tsx +342 -0
- package/src/components/resizable.tsx +55 -0
- package/src/components/role-appointment-list.render.test.ts +97 -0
- package/src/components/role-appointment-list.stories.tsx +138 -0
- package/src/components/role-appointment-list.tsx +227 -0
- package/src/components/row-actions.render.test.ts +83 -0
- package/src/components/row-actions.stories.tsx +87 -0
- package/src/components/row-actions.tsx +139 -0
- package/src/components/search-bar.render.test.ts +55 -0
- package/src/components/search-bar.stories.tsx +46 -0
- package/src/components/search-bar.tsx +118 -0
- package/src/components/search-result-row.tsx +139 -0
- package/src/components/search-results.render.test.ts +89 -0
- package/src/components/search-results.stories.tsx +126 -0
- package/src/components/search-results.tsx +215 -0
- package/src/components/search-token-chip.render.test.ts +42 -0
- package/src/components/search-token-chip.stories.tsx +42 -0
- package/src/components/search-token-chip.tsx +66 -0
- package/src/components/security-select.render.test.ts +33 -0
- package/src/components/security-select.tsx +53 -0
- package/src/components/segmented-control.tsx +70 -0
- package/src/components/select.tsx +33 -0
- package/src/components/selection-top-bar.render.test.ts +67 -0
- package/src/components/selection-top-bar.stories.tsx +39 -0
- package/src/components/selection-top-bar.tsx +95 -0
- package/src/components/sender-group-switch.tsx +63 -0
- package/src/components/sender-trust-indicator.render.test.ts +67 -0
- package/src/components/sender-trust-indicator.stories.tsx +58 -0
- package/src/components/sender-trust-indicator.tsx +87 -0
- package/src/components/settings-primitives.render.test.ts +160 -0
- package/src/components/settings-primitives.stories.tsx +80 -0
- package/src/components/settings-screen.tsx +354 -0
- package/src/components/swipeable-row.render.test.ts +106 -0
- package/src/components/swipeable-row.stories.tsx +117 -0
- package/src/components/swipeable-row.tsx +268 -0
- package/src/components/touch-list-body.render.test.ts +66 -0
- package/src/components/touch-list-body.stories.tsx +76 -0
- package/src/components/touch-list.tsx +87 -0
- package/src/components/wizard.render.test.ts +49 -0
- package/src/components/wizard.stories.tsx +113 -0
- package/src/components/wizard.tsx +321 -0
- package/src/filter-presets.test.ts +93 -0
- package/src/filter-presets.ts +101 -0
- package/src/index.ts +378 -0
- package/src/lib/cid-resolver.ts +35 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/email-layout-clamp.test.ts +109 -0
- package/src/lib/email-layout-clamp.ts +64 -0
- package/src/lib/email-render-treatment.test.ts +82 -0
- package/src/lib/email-render-treatment.ts +53 -0
- package/src/lib/email-sanitizer.test.ts +362 -0
- package/src/lib/email-sanitizer.ts +298 -0
- package/src/lib/row-keyboard.test.ts +41 -0
- package/src/lib/row-keyboard.ts +20 -0
- package/src/tokens.css +217 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { IsolatedEmailFrame } from "./isolated-email-frame.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
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
|
+
*
|
|
8
|
+
* The component receives HTML that already carries the sanitizer's layout-clamp
|
|
9
|
+
* `<style>` block, so these stories prepend the same clamp CSS to each fixture
|
|
10
|
+
* to reproduce the real pipeline. The fixtures are the real failing emails from
|
|
11
|
+
* the #727 patch chain — fixed-width `<table width="600">` newsletters that
|
|
12
|
+
* overflowed a phone — rendered at a phone width and at a desktop
|
|
13
|
+
* reading-column width.
|
|
14
|
+
*
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
|
|
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>`;
|
|
37
|
+
|
|
38
|
+
const HERO = `data:image/svg+xml;utf8,${encodeURIComponent(
|
|
39
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="600" height="200" viewBox="0 0 600 200">
|
|
40
|
+
<rect width="600" height="200" fill="#1d1d2b"/>
|
|
41
|
+
<circle cx="300" cy="100" r="60" fill="#e23a78"/>
|
|
42
|
+
</svg>`,
|
|
43
|
+
)}`;
|
|
44
|
+
|
|
45
|
+
// Node-Weekly-style FIXED-WIDTH newsletter: a `<table width="600">` whose
|
|
46
|
+
// `<td width="600">` carries the width — the exact markup that overflowed a
|
|
47
|
+
// ~390px phone (#727). The clamp CSS collapses table + cell to the frame, and
|
|
48
|
+
// the long unbroken URL must wrap rather than widen the page.
|
|
49
|
+
const NODE_WEEKLY = `${LAYOUT_CLAMP_CSS}
|
|
50
|
+
<table width="600" cellpadding="0" cellspacing="0" style="margin:0 auto;border-collapse:collapse;">
|
|
51
|
+
<tr>
|
|
52
|
+
<td width="600" style="width:600px;min-width:600px;background:#83cd29;padding:24px;font-family:Helvetica,Arial,sans-serif;color:#ffffff;">
|
|
53
|
+
<h1 style="margin:0;font-size:26px;">Node Weekly</h1>
|
|
54
|
+
<p style="margin:4px 0 0;font-size:14px;">Issue 540 — June 18, 2026</p>
|
|
55
|
+
</td>
|
|
56
|
+
</tr>
|
|
57
|
+
<tr>
|
|
58
|
+
<td width="600" style="width:600px;padding:24px;font-family:Georgia,serif;color:#1a1a1a;">
|
|
59
|
+
<h2 style="font-size:18px;color:#111;">Node.js 24 hits LTS</h2>
|
|
60
|
+
<p>The release line is now Active LTS. The permission model graduated
|
|
61
|
+
from experimental, and the built-in test runner picked up snapshot
|
|
62
|
+
testing — all without a single dependency.</p>
|
|
63
|
+
<p>https://nodejs.example/blog/release/v24.0.0-this-is-a-deliberately-very-long-unbroken-url-to-prove-wrapping</p>
|
|
64
|
+
<p><a href="https://example.com/issue/540" style="color:#43853d;">Read the full issue →</a></p>
|
|
65
|
+
</td>
|
|
66
|
+
</tr>
|
|
67
|
+
</table>
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
// Gaslicht.com-style fixed-width marketing mail: a 600px hero image edge-to-edge
|
|
71
|
+
// plus a pink CTA button, no author body padding. The hero must clamp to the
|
|
72
|
+
// frame width and the whole layout must fit a phone.
|
|
73
|
+
const GASLICHT = `${LAYOUT_CLAMP_CSS}
|
|
74
|
+
<div style="font-family: Helvetica, Arial, sans-serif; color: #1a1a1a; width: 600px; max-width: 600px;">
|
|
75
|
+
<img src="${HERO}" alt="Hero" width="600" style="display:block;width:100%;height:auto;" />
|
|
76
|
+
<div style="padding: 20px;">
|
|
77
|
+
<h1 style="font-size: 22px; margin: 0 0 8px;">Bespaar op je energierekening</h1>
|
|
78
|
+
<p style="margin: 0 0 16px; line-height: 1.5;">Vergelijk vandaag nog alle
|
|
79
|
+
energieleveranciers en stap eenvoudig over. Onze klanten besparen gemiddeld
|
|
80
|
+
honderden euro's per jaar.</p>
|
|
81
|
+
<a href="https://example.com" style="display:inline-block;background:#e23a78;color:#fff;text-decoration:none;padding:12px 28px;border-radius:6px;font-weight:bold;">Vergelijk nu</a>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
// Substack-style FLUID newsletter: a 640px max-width body that should fill the
|
|
87
|
+
// reading column on desktop and reflow on a phone — the framed treatment's
|
|
88
|
+
// `max(100%, content)` path.
|
|
89
|
+
const SUBSTACK = `${LAYOUT_CLAMP_CSS}
|
|
90
|
+
<div style="font-family: Georgia, serif; max-width: 640px; margin: 0 auto; padding: 24px; color: #1a1a1a;">
|
|
91
|
+
<h1 style="font-size: 24px; margin: 0 0 4px;">The Weekly Dispatch</h1>
|
|
92
|
+
<p style="color: #666; margin: 0 0 24px;">June 2026</p>
|
|
93
|
+
<p>This is a fluid newsletter with a 640px max-width body. On desktop it fills
|
|
94
|
+
the reading column; on a phone it reflows to the viewport with no horizontal
|
|
95
|
+
scroll.</p>
|
|
96
|
+
<p><a href="https://example.com" style="color: #268bd2;">Read online →</a></p>
|
|
97
|
+
</div>
|
|
98
|
+
`;
|
|
99
|
+
|
|
100
|
+
// Plain personal email: weak markup, only a font color — must pick up the UI
|
|
101
|
+
// font-stack + theme-aware colors so it is readable in dark mode.
|
|
102
|
+
const PLAIN = `${LAYOUT_CLAMP_CSS}
|
|
103
|
+
<div style="color:#000">
|
|
104
|
+
<p>Hi there,</p>
|
|
105
|
+
<p>Just confirming our call for tomorrow at 10am. Let me know if that still
|
|
106
|
+
works for you.</p>
|
|
107
|
+
<p>Thanks,<br>Alex</p>
|
|
108
|
+
</div>
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
const PHONE: Decorator = (Story) => (
|
|
112
|
+
<div className="overflow-x-auto" style={{ width: 390 }}>
|
|
113
|
+
<Story />
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const COLUMN: Decorator = (Story) => (
|
|
118
|
+
<div className="overflow-x-auto" style={{ width: 720 }}>
|
|
119
|
+
<Story />
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const meta: Meta<typeof IsolatedEmailFrame> = {
|
|
124
|
+
title: "Components/IsolatedEmailFrame",
|
|
125
|
+
component: IsolatedEmailFrame,
|
|
126
|
+
parameters: { layout: "fullscreen" },
|
|
127
|
+
argTypes: {
|
|
128
|
+
variant: { control: "inline-radio", options: ["plain", "framed"] },
|
|
129
|
+
isDark: { control: "boolean" },
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
export default meta;
|
|
133
|
+
|
|
134
|
+
type Story = StoryObj<typeof IsolatedEmailFrame>;
|
|
135
|
+
|
|
136
|
+
/** #727: a 600px fixed-width Node Weekly table at a 390px phone width. The inline
|
|
137
|
+
* `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. */
|
|
140
|
+
export const NodeWeeklyMobile: Story = {
|
|
141
|
+
args: { html: NODE_WEEKLY, variant: "framed", isDark: false },
|
|
142
|
+
decorators: [PHONE],
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** The same Node Weekly newsletter on a desktop reading column. */
|
|
146
|
+
export const NodeWeeklyDesktop: Story = {
|
|
147
|
+
args: { html: NODE_WEEKLY, variant: "framed", isDark: false },
|
|
148
|
+
decorators: [COLUMN],
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** 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. */
|
|
153
|
+
export const GaslichtMobile: Story = {
|
|
154
|
+
args: { html: GASLICHT, variant: "framed", isDark: false },
|
|
155
|
+
decorators: [PHONE],
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** Substack-style fluid newsletter on a desktop column: fills the reading
|
|
159
|
+
* width via the framed `max(100%, content)` path. */
|
|
160
|
+
export const SubstackDesktop: Story = {
|
|
161
|
+
args: { html: SUBSTACK, variant: "framed", isDark: false },
|
|
162
|
+
decorators: [COLUMN],
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Substack fluid newsletter reflowed to a phone width. */
|
|
166
|
+
export const SubstackMobile: Story = {
|
|
167
|
+
args: { html: SUBSTACK, variant: "framed", isDark: false },
|
|
168
|
+
decorators: [PHONE],
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** Framed newsletter on the DARK reading pane: smart-inverted to charcoal with
|
|
172
|
+
* the hero re-inverted back to natural color. */
|
|
173
|
+
export const NewsletterDarkPane: Story = {
|
|
174
|
+
args: { html: GASLICHT, variant: "framed", isDark: true },
|
|
175
|
+
parameters: { theme: "dark" },
|
|
176
|
+
decorators: [COLUMN],
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** Plain personal email: UI font-stack + theme-aware colors injected so the
|
|
180
|
+
* black-on-white author text stays readable in either theme. */
|
|
181
|
+
export const PlainEmail: Story = {
|
|
182
|
+
args: { html: PLAIN, variant: "plain", isDark: false },
|
|
183
|
+
decorators: [COLUMN],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** Plain email in dark mode: must be light text on the dark surface, never
|
|
187
|
+
* black-on-dark. */
|
|
188
|
+
export const PlainEmailDark: Story = {
|
|
189
|
+
args: { html: PLAIN, variant: "plain", isDark: true },
|
|
190
|
+
parameters: { theme: "dark" },
|
|
191
|
+
decorators: [COLUMN],
|
|
192
|
+
};
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { buildEmailSrcDoc, type EmailFrameVariant } from "./email-frame-css.js";
|
|
3
|
+
|
|
4
|
+
export interface IsolatedEmailFrameProps {
|
|
5
|
+
/**
|
|
6
|
+
* Sanitized email HTML. Must already be DOMPurify'd and carry the
|
|
7
|
+
* sanitizer's layout-clamp `<style>` block; this component only adds the
|
|
8
|
+
* colour / font / dark-mode canvas and isolates the result in a sandboxed
|
|
9
|
+
* iframe. Never pass raw, untrusted HTML here.
|
|
10
|
+
*/
|
|
11
|
+
html: string;
|
|
12
|
+
/**
|
|
13
|
+
* Render treatment:
|
|
14
|
+
*
|
|
15
|
+
* - `"plain"` — weakly-marked / personal mail. UI sans-serif + theme-aware
|
|
16
|
+
* colours are injected so black-text-on-dark is readable.
|
|
17
|
+
* - `"framed"` — designed mail (newsletter / marketing / author background).
|
|
18
|
+
* The author's colours are preserved; in dark mode the email is darkened
|
|
19
|
+
* via a smart-invert unless it opts into its own dark design.
|
|
20
|
+
*/
|
|
21
|
+
variant?: EmailFrameVariant;
|
|
22
|
+
/**
|
|
23
|
+
* Whether the app is in dark mode. The plain branch picks theme-aware
|
|
24
|
+
* colours; the framed branch decides whether to render as-authored on white
|
|
25
|
+
* or apply the smart-invert.
|
|
26
|
+
*/
|
|
27
|
+
isDark?: boolean;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Cap matches the worst real-world email we've encountered (a long
|
|
32
|
+
// daily-digest newsletter, ~30k px). Beyond this, an internal scrollbar is
|
|
33
|
+
// preferable to letting a hostile sender allocate unbounded layout.
|
|
34
|
+
const MAX_HEIGHT_PX = 50_000;
|
|
35
|
+
|
|
36
|
+
// Same spirit as MAX_HEIGHT_PX for the horizontal axis. Fixed-width newsletters
|
|
37
|
+
// top out around 900px; well past that a hostile sender is the likely cause.
|
|
38
|
+
const MAX_WIDTH_PX = 10_000;
|
|
39
|
+
|
|
40
|
+
// Below this we are on a phone: a wide fixed-layout email that cannot reflow is
|
|
41
|
+
// scaled down to fit the container instead of being clipped (#727). Wider
|
|
42
|
+
// viewports keep the content-width pin so multi-column newsletters render at
|
|
43
|
+
// their native width and the pane scrolls horizontally.
|
|
44
|
+
const NARROW_QUERY = "(max-width: 640px)";
|
|
45
|
+
|
|
46
|
+
// Don't scale below this — a heavily fixed-width newsletter on a tiny phone
|
|
47
|
+
// would otherwise shrink to unreadable. At the floor we accept that the email
|
|
48
|
+
// is downscaled as far as we'll go and the wrapper still clips the remainder
|
|
49
|
+
// (text stays larger and legible, edge content is sacrificed over a 3x shrink).
|
|
50
|
+
const MIN_SCALE = 0.4;
|
|
51
|
+
|
|
52
|
+
// sandbox flags: scripts blocked (DOMPurify already strips them; defence in
|
|
53
|
+
// depth), forms blocked, top navigation blocked. `allow-popups` +
|
|
54
|
+
// `allow-popups-to-escape-sandbox` lets `target="_blank"` links open in a new
|
|
55
|
+
// tab. `allow-same-origin` is required so the parent can read
|
|
56
|
+
// `contentDocument.body` to size the iframe to its content — safe without
|
|
57
|
+
// `allow-scripts` since there is no JS in the frame to exploit it.
|
|
58
|
+
const SANDBOX = "allow-same-origin allow-popups allow-popups-to-escape-sandbox";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Pin an iframe axis to its content's scroll size: take the larger of the body
|
|
62
|
+
* and documentElement scroll sizes, round UP so a fractional content size never
|
|
63
|
+
* leaves a 1px phantom overflow, and cap at `max` so a hostile sender can't
|
|
64
|
+
* allocate unbounded layout. Returned value is the explicit px the iframe is
|
|
65
|
+
* sized to on that axis.
|
|
66
|
+
*/
|
|
67
|
+
export const measureContentAxis = (
|
|
68
|
+
bodyScroll: number,
|
|
69
|
+
rootScroll: number,
|
|
70
|
+
max: number,
|
|
71
|
+
): number => Math.min(Math.ceil(Math.max(bodyScroll, rootScroll)), max);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The fit-to-width scale for a phone: downscale-only, so content already inside
|
|
75
|
+
* the container renders 1:1 and only genuinely-wider content shrinks. Floored at
|
|
76
|
+
* `MIN_SCALE` so a pathologically wide email doesn't shrink to unreadable. A
|
|
77
|
+
* non-positive or unknown width yields `1` (no scale) so we never divide by zero
|
|
78
|
+
* or upscale before the first measurement lands.
|
|
79
|
+
*/
|
|
80
|
+
export const computeFitScale = (
|
|
81
|
+
contentWidth: number,
|
|
82
|
+
containerWidth: number,
|
|
83
|
+
): number => {
|
|
84
|
+
if (contentWidth <= 0 || containerWidth <= 0) return 1;
|
|
85
|
+
if (contentWidth <= containerWidth) return 1;
|
|
86
|
+
return Math.max(MIN_SCALE, containerWidth / contentWidth);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const useMatchMedia = (query: string): boolean => {
|
|
90
|
+
const [matches, setMatches] = useState(() => {
|
|
91
|
+
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
92
|
+
return window.matchMedia(query).matches;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
97
|
+
const mql = window.matchMedia(query);
|
|
98
|
+
setMatches(mql.matches);
|
|
99
|
+
const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
|
|
100
|
+
mql.addEventListener("change", handler);
|
|
101
|
+
return () => mql.removeEventListener("change", handler);
|
|
102
|
+
}, [query]);
|
|
103
|
+
|
|
104
|
+
return matches;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Render untrusted (sanitized) email HTML in a sandboxed iframe that fits the
|
|
109
|
+
* viewport width on mobile and isolates the email's CSS from the app chrome.
|
|
110
|
+
*
|
|
111
|
+
* Presentational: HTML + treatment + theme come in via props; the component
|
|
112
|
+
* owns the srcDoc assembly, the content-sizing, and the fit-to-viewport
|
|
113
|
+
* decision in one place. The frame sizes itself to its content via a
|
|
114
|
+
* ResizeObserver so it grows no internal scrollbars — vertical scrolling and
|
|
115
|
+
* (on desktop) horizontal scrolling of genuinely wide email are delegated to
|
|
116
|
+
* the surrounding pane.
|
|
117
|
+
*
|
|
118
|
+
* On a phone a fixed-layout email that *can't* reflow (an inline
|
|
119
|
+
* `min-width:600px` on a `<td>` beats the sanitizer's clamp) is rendered at its
|
|
120
|
+
* natural width and the whole iframe is CSS-scaled down to fit the container —
|
|
121
|
+
* the email stays whole and readable instead of being clipped (#727).
|
|
122
|
+
*/
|
|
123
|
+
export const IsolatedEmailFrame = ({
|
|
124
|
+
html,
|
|
125
|
+
variant = "framed",
|
|
126
|
+
isDark = false,
|
|
127
|
+
className,
|
|
128
|
+
}: IsolatedEmailFrameProps) => {
|
|
129
|
+
const hostRef = useRef<HTMLDivElement>(null);
|
|
130
|
+
const ref = useRef<HTMLIFrameElement>(null);
|
|
131
|
+
const [height, setHeight] = useState(0);
|
|
132
|
+
const [width, setWidth] = useState(0);
|
|
133
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
134
|
+
|
|
135
|
+
const isNarrow = useMatchMedia(NARROW_QUERY);
|
|
136
|
+
|
|
137
|
+
const srcDoc = useMemo(
|
|
138
|
+
() => buildEmailSrcDoc(html, variant, isDark),
|
|
139
|
+
[html, variant, isDark],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
const host = hostRef.current;
|
|
144
|
+
if (!host) return;
|
|
145
|
+
const measure = () =>
|
|
146
|
+
setContainerWidth((prev) =>
|
|
147
|
+
prev === host.clientWidth ? prev : host.clientWidth,
|
|
148
|
+
);
|
|
149
|
+
measure();
|
|
150
|
+
const observer = new ResizeObserver(measure);
|
|
151
|
+
observer.observe(host);
|
|
152
|
+
return () => observer.disconnect();
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
const iframe = ref.current;
|
|
157
|
+
if (!iframe) return;
|
|
158
|
+
|
|
159
|
+
const measure = () => {
|
|
160
|
+
const doc = iframe.contentDocument;
|
|
161
|
+
if (!doc?.body) return;
|
|
162
|
+
const root = doc.documentElement;
|
|
163
|
+
const nextHeight = measureContentAxis(
|
|
164
|
+
doc.body.scrollHeight,
|
|
165
|
+
root?.scrollHeight ?? 0,
|
|
166
|
+
MAX_HEIGHT_PX,
|
|
167
|
+
);
|
|
168
|
+
setHeight((prev) => (prev === nextHeight ? prev : nextHeight));
|
|
169
|
+
const nextWidth = measureContentAxis(
|
|
170
|
+
doc.body.scrollWidth,
|
|
171
|
+
root?.scrollWidth ?? 0,
|
|
172
|
+
MAX_WIDTH_PX,
|
|
173
|
+
);
|
|
174
|
+
setWidth((prev) => (prev === nextWidth ? prev : nextWidth));
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
let observer: ResizeObserver | undefined;
|
|
178
|
+
const handleLoad = () => {
|
|
179
|
+
measure();
|
|
180
|
+
const doc = iframe.contentDocument;
|
|
181
|
+
if (!doc?.body) return;
|
|
182
|
+
observer = new ResizeObserver(measure);
|
|
183
|
+
observer.observe(doc.body);
|
|
184
|
+
if (doc.documentElement) observer.observe(doc.documentElement);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
iframe.addEventListener("load", handleLoad);
|
|
188
|
+
return () => {
|
|
189
|
+
iframe.removeEventListener("load", handleLoad);
|
|
190
|
+
observer?.disconnect();
|
|
191
|
+
};
|
|
192
|
+
}, []);
|
|
193
|
+
|
|
194
|
+
// The fit-to-viewport decision, owned in one place:
|
|
195
|
+
// - Phone (`isNarrow`): render the iframe at its natural content width and
|
|
196
|
+
// CSS-scale the whole frame down to the container, so a fixed-width
|
|
197
|
+
// newsletter that can't reflow fits the phone whole instead of being
|
|
198
|
+
// clipped (#727). Content already within the container renders 1:1.
|
|
199
|
+
// - Desktop framed: `max(100%, content)` so a narrow-max-width newsletter
|
|
200
|
+
// (Substack's 640px body) fills the reading column while a genuinely wide
|
|
201
|
+
// fixed-layout email grows past the pane and lets the pane scroll.
|
|
202
|
+
// - Plain / pre-measurement: pin to measured content width, 100% until known.
|
|
203
|
+
const scale = isNarrow ? computeFitScale(width, containerWidth) : 1;
|
|
204
|
+
const scaled = scale < 1;
|
|
205
|
+
|
|
206
|
+
const frameWidth = scaled
|
|
207
|
+
? `${width}px`
|
|
208
|
+
: isNarrow
|
|
209
|
+
? "100%"
|
|
210
|
+
: variant === "framed" && width > 0
|
|
211
|
+
? `max(100%, ${width}px)`
|
|
212
|
+
: width === 0
|
|
213
|
+
? "100%"
|
|
214
|
+
: `${width}px`;
|
|
215
|
+
|
|
216
|
+
const frameHeight = height === 0 ? "1px" : `${height}px`;
|
|
217
|
+
|
|
218
|
+
const iframe = (
|
|
219
|
+
<iframe
|
|
220
|
+
ref={ref}
|
|
221
|
+
title="Email content"
|
|
222
|
+
sandbox={SANDBOX}
|
|
223
|
+
srcDoc={srcDoc}
|
|
224
|
+
className={scaled ? undefined : className}
|
|
225
|
+
scrolling="no"
|
|
226
|
+
style={{
|
|
227
|
+
width: frameWidth,
|
|
228
|
+
maxWidth: scaled ? "none" : undefined,
|
|
229
|
+
border: "none",
|
|
230
|
+
display: "block",
|
|
231
|
+
height: frameHeight,
|
|
232
|
+
overflow: "hidden",
|
|
233
|
+
transform: scaled ? `scale(${scale})` : undefined,
|
|
234
|
+
transformOrigin: scaled ? "top left" : undefined,
|
|
235
|
+
// Both branches carry their own color-scheme (and, for the framed
|
|
236
|
+
// dark-invert case, the darkening filter) in the injected base CSS,
|
|
237
|
+
// so the iframe element stays "normal" rather than pinning a scheme
|
|
238
|
+
// that would fight a dark opt-in or the invert.
|
|
239
|
+
colorScheme: "normal",
|
|
240
|
+
}}
|
|
241
|
+
/>
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// When scaled, the iframe's layout box stays its natural (un-transformed)
|
|
245
|
+
// size, so it must sit in a wrapper sized to the SCALED footprint and clip
|
|
246
|
+
// the overflow — otherwise the surrounding pane sees the natural width and
|
|
247
|
+
// grows a scrollbar.
|
|
248
|
+
return (
|
|
249
|
+
<div ref={hostRef} className={scaled ? className : undefined}>
|
|
250
|
+
{scaled ? (
|
|
251
|
+
<div
|
|
252
|
+
style={{
|
|
253
|
+
width: "100%",
|
|
254
|
+
height: `${Math.ceil(height * scale)}px`,
|
|
255
|
+
overflow: "hidden",
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
{iframe}
|
|
259
|
+
</div>
|
|
260
|
+
) : (
|
|
261
|
+
iframe
|
|
262
|
+
)}
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
|
|
4
|
+
export type KbdProps = HTMLAttributes<HTMLElement>;
|
|
5
|
+
|
|
6
|
+
/** Inline keyboard-key hint — keyboard-first UI surfaces these everywhere. */
|
|
7
|
+
export function Kbd({ className, ...props }: KbdProps) {
|
|
8
|
+
return (
|
|
9
|
+
<kbd
|
|
10
|
+
className={cn(
|
|
11
|
+
"inline-flex h-4 min-w-4 items-center justify-center rounded-xs border border-line bg-surface-sunken px-1 font-sans text-2xs text-fg-muted",
|
|
12
|
+
className,
|
|
13
|
+
)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { Kbd } from "./kbd.js";
|
|
4
|
+
|
|
5
|
+
export interface KeyboardHint {
|
|
6
|
+
/** One or more keys shown as `Kbd` chips. */
|
|
7
|
+
keys: string[];
|
|
8
|
+
/** What the keys do. */
|
|
9
|
+
label: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface KeyboardHintBarProps {
|
|
13
|
+
/** Override the default hint set. */
|
|
14
|
+
hints?: KeyboardHint[];
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The persistent message-list footer hint set (desktop). */
|
|
19
|
+
export const defaultKeyboardHints: KeyboardHint[] = [
|
|
20
|
+
{ keys: ["j", "k"], label: "navigate" },
|
|
21
|
+
{ keys: ["m"], label: "mute" },
|
|
22
|
+
{ keys: ["?"], label: "all shortcuts" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The keyboard-shortcut hint footer under the message list. Keyboard-first
|
|
27
|
+
* discoverability — the shortcuts aren't hidden behind the `?` modal. The host
|
|
28
|
+
* renders it on desktop only (gating by layout tier, not a CSS breakpoint),
|
|
29
|
+
* since a key-hint footer on a touch device is noise.
|
|
30
|
+
*/
|
|
31
|
+
export function KeyboardHintBar({
|
|
32
|
+
hints = defaultKeyboardHints,
|
|
33
|
+
className,
|
|
34
|
+
}: KeyboardHintBarProps) {
|
|
35
|
+
return (
|
|
36
|
+
<footer
|
|
37
|
+
className={cn(
|
|
38
|
+
"flex items-center gap-2 border-t border-line px-row-inset py-1 text-2xs text-fg-subtle",
|
|
39
|
+
className,
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
{hints.map((hint) => (
|
|
43
|
+
<HintGroup key={hint.label} hint={hint} />
|
|
44
|
+
))}
|
|
45
|
+
</footer>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function HintGroup({ hint }: { hint: KeyboardHint }): ReactNode {
|
|
50
|
+
return (
|
|
51
|
+
<span className="flex items-center gap-1">
|
|
52
|
+
{hint.keys.map((key) => (
|
|
53
|
+
<Kbd key={key}>{key}</Kbd>
|
|
54
|
+
))}
|
|
55
|
+
<span>{hint.label}</span>
|
|
56
|
+
</span>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
|
|
4
|
+
export interface ListItemProps extends HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
leading?: ReactNode;
|
|
6
|
+
trailing?: ReactNode;
|
|
7
|
+
active?: boolean;
|
|
8
|
+
unread?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ListItem({
|
|
12
|
+
leading,
|
|
13
|
+
trailing,
|
|
14
|
+
active,
|
|
15
|
+
unread,
|
|
16
|
+
className,
|
|
17
|
+
children,
|
|
18
|
+
...props
|
|
19
|
+
}: ListItemProps) {
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
className={cn(
|
|
23
|
+
"group relative flex cursor-pointer items-start gap-3 px-4 py-3 transition-colors",
|
|
24
|
+
active ? "bg-accent-2-soft" : "hover:bg-surface-sunken",
|
|
25
|
+
className,
|
|
26
|
+
)}
|
|
27
|
+
{...props}
|
|
28
|
+
>
|
|
29
|
+
{unread && (
|
|
30
|
+
<span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
|
|
31
|
+
)}
|
|
32
|
+
{leading && <div className="shrink-0 pt-0.5">{leading}</div>}
|
|
33
|
+
<div className="min-w-0 flex-1">{children}</div>
|
|
34
|
+
{trailing && (
|
|
35
|
+
<div className="shrink-0 text-2xs text-fg-subtle">{trailing}</div>
|
|
36
|
+
)}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { MailActionToolbar } from "./mail-action-toolbar.js";
|
|
6
|
+
|
|
7
|
+
describe("MailActionToolbar", () => {
|
|
8
|
+
it("renders the triage cluster by default (desktop reading-pane behavior)", () => {
|
|
9
|
+
const html = renderToString(
|
|
10
|
+
createElement(MailActionToolbar, { hasThread: true }),
|
|
11
|
+
);
|
|
12
|
+
assert.match(html, /aria-label="Reply"/);
|
|
13
|
+
assert.match(html, /aria-label="Move to Trash"/);
|
|
14
|
+
assert.match(html, /aria-label="Flag"/);
|
|
15
|
+
assert.match(html, /aria-label="Move to mailbox"/);
|
|
16
|
+
// No archive verb — Remit is IMAP-backed (move-to-folder is the equivalent).
|
|
17
|
+
assert.doesNotMatch(html, /aria-label="Archive"/);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("hides the triage cluster when showTriage is false (mobile footer)", () => {
|
|
21
|
+
const html = renderToString(
|
|
22
|
+
createElement(MailActionToolbar, { hasThread: true, showTriage: false }),
|
|
23
|
+
);
|
|
24
|
+
// Reply verbs stay; triage is gone.
|
|
25
|
+
assert.match(html, /aria-label="Reply"/);
|
|
26
|
+
assert.match(html, /aria-label="Reply all"/);
|
|
27
|
+
assert.match(html, /aria-label="Forward"/);
|
|
28
|
+
assert.doesNotMatch(html, /aria-label="Archive"/);
|
|
29
|
+
assert.doesNotMatch(html, /aria-label="Move to Trash"/);
|
|
30
|
+
assert.doesNotMatch(html, /aria-label="Flag"/);
|
|
31
|
+
assert.doesNotMatch(html, /aria-label="Move to mailbox"/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("surfaces the unavailable hint when no thread is open", () => {
|
|
35
|
+
const html = renderToString(
|
|
36
|
+
createElement(MailActionToolbar, {
|
|
37
|
+
hasThread: false,
|
|
38
|
+
unavailableHint: "Open a message first",
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
assert.match(html, /Open a message first/);
|
|
42
|
+
});
|
|
43
|
+
});
|