@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.
Files changed (188) hide show
  1. package/package.json +50 -0
  2. package/src/components/address-display.render.test.ts +93 -0
  3. package/src/components/address-display.stories.tsx +55 -0
  4. package/src/components/address-display.tsx +132 -0
  5. package/src/components/address-tag.render.test.ts +39 -0
  6. package/src/components/address-tag.stories.tsx +41 -0
  7. package/src/components/address-tag.tsx +38 -0
  8. package/src/components/app-password-hint.render.test.ts +23 -0
  9. package/src/components/app-password-hint.stories.tsx +19 -0
  10. package/src/components/app-password-hint.tsx +24 -0
  11. package/src/components/app-shell-slotted.render.test.ts +128 -0
  12. package/src/components/app-shell-slotted.tsx +267 -0
  13. package/src/components/app-shell-types.ts +328 -0
  14. package/src/components/app-shell.render.test.ts +415 -0
  15. package/src/components/app-shell.tsx +277 -0
  16. package/src/components/auth-card.render.test.ts +24 -0
  17. package/src/components/auth-card.stories.tsx +50 -0
  18. package/src/components/auth-card.tsx +32 -0
  19. package/src/components/auth-footer.render.test.ts +17 -0
  20. package/src/components/auth-footer.stories.tsx +23 -0
  21. package/src/components/auth-footer.tsx +14 -0
  22. package/src/components/auth-hero.render.test.ts +31 -0
  23. package/src/components/auth-hero.stories.tsx +19 -0
  24. package/src/components/auth-hero.tsx +41 -0
  25. package/src/components/auto-moved-badge.render.test.ts +42 -0
  26. package/src/components/auto-moved-badge.stories.tsx +48 -0
  27. package/src/components/auto-moved-badge.tsx +57 -0
  28. package/src/components/avatar.tsx +60 -0
  29. package/src/components/badge.tsx +49 -0
  30. package/src/components/banner.render.test.ts +58 -0
  31. package/src/components/banner.stories.tsx +92 -0
  32. package/src/components/banner.tsx +81 -0
  33. package/src/components/bottom-sheet.render.test.ts +48 -0
  34. package/src/components/bottom-sheet.stories.tsx +71 -0
  35. package/src/components/bottom-sheet.tsx +146 -0
  36. package/src/components/brief-section.render.test.ts +98 -0
  37. package/src/components/brief-section.stories.tsx +68 -0
  38. package/src/components/brief-section.tsx +102 -0
  39. package/src/components/brief-sections.render.test.ts +72 -0
  40. package/src/components/brief-sections.stories.tsx +178 -0
  41. package/src/components/brief-sections.tsx +201 -0
  42. package/src/components/button.tsx +48 -0
  43. package/src/components/card.tsx +48 -0
  44. package/src/components/category-badge.render.test.ts +40 -0
  45. package/src/components/category-badge.stories.tsx +37 -0
  46. package/src/components/category-badge.tsx +71 -0
  47. package/src/components/checkbox.render.test.ts +65 -0
  48. package/src/components/checkbox.stories.tsx +77 -0
  49. package/src/components/checkbox.tsx +78 -0
  50. package/src/components/compose-action-bar.render.test.ts +47 -0
  51. package/src/components/compose-action-bar.stories.tsx +35 -0
  52. package/src/components/compose-action-bar.tsx +95 -0
  53. package/src/components/compose-form-shell.render.test.ts +54 -0
  54. package/src/components/compose-form-shell.stories.tsx +96 -0
  55. package/src/components/compose-form-shell.tsx +49 -0
  56. package/src/components/danger-zone-section.tsx +36 -0
  57. package/src/components/dialog.render.test.ts +55 -0
  58. package/src/components/dialog.tsx +104 -0
  59. package/src/components/email-frame-css.ts +137 -0
  60. package/src/components/field-label.render.test.ts +16 -0
  61. package/src/components/field-label.tsx +19 -0
  62. package/src/components/filter-sheet.render.test.ts +76 -0
  63. package/src/components/filter-sheet.stories.tsx +146 -0
  64. package/src/components/filter-sheet.tsx +316 -0
  65. package/src/components/folder-role.tsx +66 -0
  66. package/src/components/input.tsx +44 -0
  67. package/src/components/intelligence-panel.stories.tsx +149 -0
  68. package/src/components/intelligence-panel.tsx +546 -0
  69. package/src/components/isolated-email-frame.render.test.ts +147 -0
  70. package/src/components/isolated-email-frame.stories.tsx +192 -0
  71. package/src/components/isolated-email-frame.tsx +265 -0
  72. package/src/components/kbd.tsx +17 -0
  73. package/src/components/keyboard-hint-bar.tsx +58 -0
  74. package/src/components/list-item.tsx +39 -0
  75. package/src/components/mail-action-toolbar.render.test.ts +43 -0
  76. package/src/components/mail-action-toolbar.tsx +180 -0
  77. package/src/components/mail-header.render.test.ts +69 -0
  78. package/src/components/mail-header.stories.tsx +233 -0
  79. package/src/components/mail-header.tsx +123 -0
  80. package/src/components/message-body-view.render.test.ts +59 -0
  81. package/src/components/message-body-view.stories.tsx +146 -0
  82. package/src/components/message-body-view.tsx +150 -0
  83. package/src/components/message-header.render.test.ts +58 -0
  84. package/src/components/message-header.stories.tsx +79 -0
  85. package/src/components/message-header.tsx +59 -0
  86. package/src/components/message-list-pane.render.test.ts +136 -0
  87. package/src/components/message-list-pane.stories.tsx +246 -0
  88. package/src/components/message-list-pane.tsx +248 -0
  89. package/src/components/message-list-state.render.test.ts +72 -0
  90. package/src/components/message-list-state.tsx +97 -0
  91. package/src/components/message-row.render.test.ts +48 -0
  92. package/src/components/message-row.stories.tsx +121 -0
  93. package/src/components/message-row.tsx +231 -0
  94. package/src/components/mobile-message-action-bar.render.test.ts +87 -0
  95. package/src/components/mobile-message-action-bar.stories.tsx +57 -0
  96. package/src/components/mobile-message-action-bar.tsx +175 -0
  97. package/src/components/mobile-reading-pane.render.test.ts +102 -0
  98. package/src/components/mobile-reading-pane.tsx +188 -0
  99. package/src/components/mobile-search-view.stories.tsx +247 -0
  100. package/src/components/mobile-search-view.tsx +100 -0
  101. package/src/components/move-mailbox-picker.render.test.ts +113 -0
  102. package/src/components/move-mailbox-picker.stories.tsx +76 -0
  103. package/src/components/move-mailbox-picker.tsx +324 -0
  104. package/src/components/nav-sidebar.render.test.ts +351 -0
  105. package/src/components/nav-sidebar.stories.tsx +216 -0
  106. package/src/components/nav-sidebar.tsx +625 -0
  107. package/src/components/outbox-row.render.test.ts +61 -0
  108. package/src/components/outbox-row.stories.tsx +53 -0
  109. package/src/components/outbox-row.tsx +120 -0
  110. package/src/components/outbox-status-badge.tsx +60 -0
  111. package/src/components/pane-layout.render.test.ts +105 -0
  112. package/src/components/popover-menu.render.test.ts +32 -0
  113. package/src/components/popover-menu.stories.tsx +57 -0
  114. package/src/components/popover-menu.tsx +110 -0
  115. package/src/components/primitives.stories.tsx +173 -0
  116. package/src/components/pull-to-refresh.render.test.ts +46 -0
  117. package/src/components/pull-to-refresh.stories.tsx +71 -0
  118. package/src/components/pull-to-refresh.tsx +54 -0
  119. package/src/components/quoted-text.render.test.ts +39 -0
  120. package/src/components/quoted-text.stories.tsx +67 -0
  121. package/src/components/quoted-text.tsx +67 -0
  122. package/src/components/reading-pane-empty.tsx +44 -0
  123. package/src/components/reading-pane.render.test.ts +148 -0
  124. package/src/components/reading-pane.stories.tsx +194 -0
  125. package/src/components/reading-pane.tsx +351 -0
  126. package/src/components/rescue-banner.render.test.ts +32 -0
  127. package/src/components/rescue-banner.stories.tsx +31 -0
  128. package/src/components/rescue-banner.tsx +50 -0
  129. package/src/components/rescue-candidate-row.render.test.ts +68 -0
  130. package/src/components/rescue-candidate-row.stories.tsx +108 -0
  131. package/src/components/rescue-candidate-row.tsx +94 -0
  132. package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
  133. package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
  134. package/src/components/rescue-from-spam-flow.tsx +342 -0
  135. package/src/components/resizable.tsx +55 -0
  136. package/src/components/role-appointment-list.render.test.ts +97 -0
  137. package/src/components/role-appointment-list.stories.tsx +138 -0
  138. package/src/components/role-appointment-list.tsx +227 -0
  139. package/src/components/row-actions.render.test.ts +83 -0
  140. package/src/components/row-actions.stories.tsx +87 -0
  141. package/src/components/row-actions.tsx +139 -0
  142. package/src/components/search-bar.render.test.ts +55 -0
  143. package/src/components/search-bar.stories.tsx +46 -0
  144. package/src/components/search-bar.tsx +118 -0
  145. package/src/components/search-result-row.tsx +139 -0
  146. package/src/components/search-results.render.test.ts +89 -0
  147. package/src/components/search-results.stories.tsx +126 -0
  148. package/src/components/search-results.tsx +215 -0
  149. package/src/components/search-token-chip.render.test.ts +42 -0
  150. package/src/components/search-token-chip.stories.tsx +42 -0
  151. package/src/components/search-token-chip.tsx +66 -0
  152. package/src/components/security-select.render.test.ts +33 -0
  153. package/src/components/security-select.tsx +53 -0
  154. package/src/components/segmented-control.tsx +70 -0
  155. package/src/components/select.tsx +33 -0
  156. package/src/components/selection-top-bar.render.test.ts +67 -0
  157. package/src/components/selection-top-bar.stories.tsx +39 -0
  158. package/src/components/selection-top-bar.tsx +95 -0
  159. package/src/components/sender-group-switch.tsx +63 -0
  160. package/src/components/sender-trust-indicator.render.test.ts +67 -0
  161. package/src/components/sender-trust-indicator.stories.tsx +58 -0
  162. package/src/components/sender-trust-indicator.tsx +87 -0
  163. package/src/components/settings-primitives.render.test.ts +160 -0
  164. package/src/components/settings-primitives.stories.tsx +80 -0
  165. package/src/components/settings-screen.tsx +354 -0
  166. package/src/components/swipeable-row.render.test.ts +106 -0
  167. package/src/components/swipeable-row.stories.tsx +117 -0
  168. package/src/components/swipeable-row.tsx +268 -0
  169. package/src/components/touch-list-body.render.test.ts +66 -0
  170. package/src/components/touch-list-body.stories.tsx +76 -0
  171. package/src/components/touch-list.tsx +87 -0
  172. package/src/components/wizard.render.test.ts +49 -0
  173. package/src/components/wizard.stories.tsx +113 -0
  174. package/src/components/wizard.tsx +321 -0
  175. package/src/filter-presets.test.ts +93 -0
  176. package/src/filter-presets.ts +101 -0
  177. package/src/index.ts +378 -0
  178. package/src/lib/cid-resolver.ts +35 -0
  179. package/src/lib/cn.ts +7 -0
  180. package/src/lib/email-layout-clamp.test.ts +109 -0
  181. package/src/lib/email-layout-clamp.ts +64 -0
  182. package/src/lib/email-render-treatment.test.ts +82 -0
  183. package/src/lib/email-render-treatment.ts +53 -0
  184. package/src/lib/email-sanitizer.test.ts +362 -0
  185. package/src/lib/email-sanitizer.ts +298 -0
  186. package/src/lib/row-keyboard.test.ts +41 -0
  187. package/src/lib/row-keyboard.ts +20 -0
  188. 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 &rarr;</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 &rarr;</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
+ });