@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,146 @@
1
+ import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
2
+ import { MessageBodyView } from "./message-body-view.js";
3
+
4
+ /**
5
+ * `MessageBodyView` is the single source of truth for rendering an email body:
6
+ * it sanitizes the raw HTML (DOMPurify + privacy/XSS scrubbing), classifies it
7
+ * as framed (designed mail) or plain (theme-aware), and hands the result to the
8
+ * sandboxed `IsolatedEmailFrame`. The app's `MessageBody` and the kit reading
9
+ * panes both compose it, so Storybook renders email exactly as the app does
10
+ * (#940) — sandbox, flush layout and #727 scale-to-fit all visible.
11
+ *
12
+ * Unlike the `IsolatedEmailFrame` stories, these fixtures pass RAW author HTML:
13
+ * the component runs the real sanitizer, so the layout-clamp `<style>` and the
14
+ * image-blocking are produced here, not hand-prepended.
15
+ */
16
+
17
+ const NODE_WEEKLY = `
18
+ <table width="600" cellpadding="0" cellspacing="0" style="margin:0 auto;border-collapse:collapse;">
19
+ <tr>
20
+ <td width="600" style="width:600px;min-width:600px;background:#83cd29;padding:24px;font-family:Helvetica,Arial,sans-serif;color:#ffffff;">
21
+ <h1 style="margin:0;font-size:26px;">Node Weekly</h1>
22
+ <p style="margin:4px 0 0;font-size:14px;">Issue 540 — June 18, 2026</p>
23
+ </td>
24
+ </tr>
25
+ <tr>
26
+ <td width="600" style="width:600px;padding:24px;font-family:Georgia,serif;color:#1a1a1a;">
27
+ <h2 style="font-size:18px;color:#111;">Node.js 24 hits LTS</h2>
28
+ <p>The release line is now Active LTS. The permission model graduated
29
+ from experimental, and the built-in test runner picked up snapshot
30
+ testing — all without a single dependency.</p>
31
+ <p><a href="https://example.com/issue/540" style="color:#43853d;">Read the full issue &rarr;</a></p>
32
+ </td>
33
+ </tr>
34
+ </table>
35
+ `;
36
+
37
+ const PLAIN = `
38
+ <div style="color:#000">
39
+ <p>Hi there,</p>
40
+ <p>Just confirming our call for tomorrow at 10am. Let me know if that still
41
+ works for you.</p>
42
+ <p>Thanks,<br>Alex</p>
43
+ </div>
44
+ `;
45
+
46
+ // Marketing mail with two remote images — with images blocked the sanitizer
47
+ // swaps them for placeholders and the privacy notice slot reports the count.
48
+ const WITH_REMOTE_IMAGES = `
49
+ <div style="font-family: Helvetica, Arial, sans-serif; background:#ffffff; color:#1a1a1a; padding:20px;">
50
+ <img src="https://tracker.example/hero.png" alt="Hero" width="560" />
51
+ <h1 style="font-size:22px;">Summer sale</h1>
52
+ <p>Up to 40% off everything this weekend.</p>
53
+ <img src="https://tracker.example/footer.png" alt="Footer" width="560" />
54
+ </div>
55
+ `;
56
+
57
+ const COLUMN: Decorator = (Story) => (
58
+ <div style={{ width: 720 }}>
59
+ <Story />
60
+ </div>
61
+ );
62
+
63
+ const PHONE: Decorator = (Story) => (
64
+ <div style={{ width: 390 }}>
65
+ <Story />
66
+ </div>
67
+ );
68
+
69
+ const meta: Meta<typeof MessageBodyView> = {
70
+ title: "Components/MessageBodyView",
71
+ component: MessageBodyView,
72
+ parameters: { layout: "padded" },
73
+ argTypes: {
74
+ isDark: { control: "boolean" },
75
+ allowImages: { control: "boolean" },
76
+ },
77
+ };
78
+ export default meta;
79
+
80
+ type Story = StoryObj<typeof MessageBodyView>;
81
+
82
+ /** A fixed-width newsletter rendered through the real pipeline: sanitized,
83
+ * classified framed, rendered in the sandboxed frame. */
84
+ export const Newsletter: Story = {
85
+ args: { html: NODE_WEEKLY, category: "newsletter", allowImages: true },
86
+ decorators: [COLUMN],
87
+ };
88
+
89
+ /** The same newsletter on a phone width — #727 scale-to-fit keeps it whole. */
90
+ export const NewsletterMobile: Story = {
91
+ args: { html: NODE_WEEKLY, category: "newsletter", allowImages: true },
92
+ decorators: [PHONE],
93
+ };
94
+
95
+ /** Newsletter on the dark reading pane: smart-inverted to charcoal. */
96
+ export const NewsletterDark: Story = {
97
+ args: {
98
+ html: NODE_WEEKLY,
99
+ category: "newsletter",
100
+ allowImages: true,
101
+ isDark: true,
102
+ },
103
+ parameters: { theme: "dark" },
104
+ decorators: [COLUMN],
105
+ };
106
+
107
+ /** Plain personal mail: UI font-stack + theme-aware colors injected. */
108
+ export const Plain: Story = {
109
+ args: { html: PLAIN, category: "personal", allowImages: true },
110
+ decorators: [COLUMN],
111
+ };
112
+
113
+ /** Plain mail in dark mode: light text on the dark surface, never black-on-dark. */
114
+ export const PlainDark: Story = {
115
+ args: { html: PLAIN, category: "personal", allowImages: true, isDark: true },
116
+ parameters: { theme: "dark" },
117
+ decorators: [COLUMN],
118
+ };
119
+
120
+ /** Images blocked: the sanitizer swaps remote images for placeholders and the
121
+ * privacy notice reports the count via `renderBlockedNotice`. */
122
+ export const ImagesBlocked: Story = {
123
+ args: {
124
+ html: WITH_REMOTE_IMAGES,
125
+ category: "marketing",
126
+ allowImages: false,
127
+ renderBlockedNotice: (count) => (
128
+ <div className="mb-3 rounded-md bg-surface-sunken/50 px-3 py-2 text-sm text-fg-muted">
129
+ {count} image{count > 1 ? "s" : ""} blocked for privacy
130
+ </div>
131
+ ),
132
+ },
133
+ decorators: [COLUMN],
134
+ };
135
+
136
+ /** Same mail with images loaded — the remote images render and no notice shows. */
137
+ export const ImagesLoaded: Story = {
138
+ args: { html: WITH_REMOTE_IMAGES, category: "marketing", allowImages: true },
139
+ decorators: [COLUMN],
140
+ };
141
+
142
+ /** No body content: the empty-state fallback. */
143
+ export const Empty: Story = {
144
+ args: { html: undefined, text: undefined },
145
+ decorators: [COLUMN],
146
+ };
@@ -0,0 +1,150 @@
1
+ import { type ReactNode, useMemo } from "react";
2
+ import type { CidResolver } from "../lib/cid-resolver.js";
3
+ import { cn } from "../lib/cn.js";
4
+ import {
5
+ classifyEmailRenderTreatment,
6
+ type EmailRenderCategory,
7
+ } from "../lib/email-render-treatment.js";
8
+ import { createEmailSanitizer } from "../lib/email-sanitizer.js";
9
+ import { IsolatedEmailFrame } from "./isolated-email-frame.js";
10
+
11
+ export type { EmailRenderCategory } from "../lib/email-render-treatment.js";
12
+
13
+ export interface MessageBodyViewProps {
14
+ /**
15
+ * Rendered email HTML (the `text/html` part's body, or a locally-rendered
16
+ * draft). Passed RAW — this component sanitizes it before it reaches the
17
+ * iframe. The single source of truth for "render an email body": app
18
+ * `MessageBody` and the kit reading panes both compose it, so Storybook
19
+ * shows the same sanitized, sandboxed rendering as the live app.
20
+ */
21
+ html?: string;
22
+ /** Plain-text fallback for messages with no HTML part. */
23
+ text?: string;
24
+ /** App dark mode — drives the plain/framed dark canvas in the frame. */
25
+ isDark?: boolean;
26
+ /**
27
+ * Message category (personal/newsletter/marketing/…). Together with the
28
+ * sanitizer's author-background detection this picks the framed vs plain
29
+ * treatment.
30
+ */
31
+ category?: EmailRenderCategory;
32
+ /**
33
+ * Whether external images are allowed to load. When false the sanitizer
34
+ * swaps remote `<img src>` for a placeholder and stamps `data-blocked-src`;
35
+ * `renderBlockedNotice` is then called with the count so the caller can
36
+ * offer "load images".
37
+ */
38
+ allowImages?: boolean;
39
+ /** Resolve `cid:` inline-image references to fetchable URLs. */
40
+ resolveCid?: CidResolver;
41
+ /** Extra classes for the body wrapper. */
42
+ className?: string;
43
+ /**
44
+ * Render a notice above the body when external images were blocked. The
45
+ * caller owns the "load once / always trust" affordances (they touch app
46
+ * trust state), so this component only reports the count. Not called, or
47
+ * called with `0`, when nothing was blocked.
48
+ */
49
+ renderBlockedNotice?: (blockedImageCount: number) => ReactNode;
50
+ }
51
+
52
+ const EmptyBody = () => (
53
+ <p className="text-fg-muted text-sm italic">
54
+ This message has no body content.
55
+ </p>
56
+ );
57
+
58
+ /**
59
+ * Render an email body the way the app does: sanitize the raw HTML
60
+ * (DOMPurify + privacy/XSS scrubbing), classify it as framed (designed mail —
61
+ * author colors preserved) or plain (theme-aware base CSS), then hand the
62
+ * sanitized HTML to the sandboxed `IsolatedEmailFrame`. Never paints raw HTML
63
+ * into the app DOM — the only safe contract for untrusted mail.
64
+ */
65
+ export const MessageBodyView = ({
66
+ html,
67
+ text,
68
+ isDark = false,
69
+ category,
70
+ allowImages = false,
71
+ resolveCid,
72
+ className,
73
+ renderBlockedNotice,
74
+ }: MessageBodyViewProps) => {
75
+ const sanitized = useMemo(() => {
76
+ // DOMPurify needs a DOM. In a no-DOM context (SSR / unit render) we
77
+ // cannot sanitize, so we never emit the HTML — rendering unsanitized
78
+ // mail is not an option. The browser app always has a DOM; this guard
79
+ // only affects server / test rendering.
80
+ if (!html || typeof document === "undefined") return null;
81
+ const sanitize = createEmailSanitizer({
82
+ allowExternalImages: allowImages,
83
+ resolveCid,
84
+ });
85
+ return sanitize(html);
86
+ }, [html, allowImages, resolveCid]);
87
+
88
+ const sanitizedHtml = sanitized?.html ?? null;
89
+
90
+ const { framed, isPlain } = classifyEmailRenderTreatment(
91
+ category,
92
+ sanitized?.hasAuthorBackground ?? false,
93
+ );
94
+
95
+ const blockedImageCount = useMemo(() => {
96
+ if (!sanitizedHtml || allowImages) return 0;
97
+ return (sanitizedHtml.match(/data-blocked-src/g) || []).length;
98
+ }, [sanitizedHtml, allowImages]);
99
+
100
+ if (!sanitizedHtml && !text) {
101
+ return (
102
+ <div className={cn("message-body", className)}>
103
+ <EmptyBody />
104
+ </div>
105
+ );
106
+ }
107
+
108
+ return (
109
+ <div className={cn("message-body", className)}>
110
+ {blockedImageCount > 0 && renderBlockedNotice?.(blockedImageCount)}
111
+
112
+ {sanitizedHtml ? (
113
+ // Email HTML renders inside a sandboxed iframe so its own CSS and any
114
+ // (already-DOMPurify'd) markup cannot bleed into the app chrome. The
115
+ // frame sizes itself to its content; its sandbox omits `allow-scripts`
116
+ // so even a hypothetical sanitizer escape can't execute.
117
+ framed ? (
118
+ // Full-width wrapper so a fluid newsletter fills the reading column;
119
+ // max-w-full + overflow-x-auto trap any residual wide content inside
120
+ // this box rather than dragging the page on mobile. No border,
121
+ // padding or background — the email renders flush (#727).
122
+ <div className="w-full max-w-full overflow-x-auto">
123
+ <IsolatedEmailFrame
124
+ html={sanitizedHtml}
125
+ variant="framed"
126
+ isDark={isDark}
127
+ />
128
+ </div>
129
+ ) : (
130
+ // `lg:max-w-2xl` caps the reading column on desktop; `max-w-full`
131
+ // keeps the box within the viewport on mobile. `overflow-x-auto`
132
+ // traps residual wide content INSIDE this box on a phone (#727).
133
+ <div className="max-w-full overflow-x-auto lg:max-w-2xl">
134
+ <IsolatedEmailFrame
135
+ html={sanitizedHtml}
136
+ variant={isPlain ? "plain" : "framed"}
137
+ isDark={isDark}
138
+ />
139
+ </div>
140
+ )
141
+ ) : text ? (
142
+ <pre className="email-text whitespace-pre-wrap text-sm leading-relaxed">
143
+ {text}
144
+ </pre>
145
+ ) : (
146
+ <EmptyBody />
147
+ )}
148
+ </div>
149
+ );
150
+ };
@@ -0,0 +1,58 @@
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 type { EnvelopeAddress } from "./address-display.js";
6
+ import { MessageHeader, type MessageHeaderProps } from "./message-header.js";
7
+
8
+ const ada: EnvelopeAddress = {
9
+ displayName: "Ada Lovelace",
10
+ normalizedEmail: "ada@example.com",
11
+ };
12
+
13
+ const base: MessageHeaderProps = {
14
+ subject: "Quarterly numbers",
15
+ from: [ada],
16
+ to: [{ normalizedEmail: "team@example.com" }],
17
+ date: "Mon, 23 Jun 2026, 14:00",
18
+ senderTrust: "wellknown",
19
+ };
20
+
21
+ describe("MessageHeader", () => {
22
+ it("renders subject, addresses and the formatted date", () => {
23
+ const html = renderToString(createElement(MessageHeader, base));
24
+ assert.match(html, /Quarterly numbers/);
25
+ assert.match(html, /Ada Lovelace/);
26
+ assert.match(html, /team@example.com/);
27
+ assert.match(html, /Mon, 23 Jun 2026, 14:00/);
28
+ });
29
+
30
+ it("shows a fallback for a missing subject", () => {
31
+ const html = renderToString(
32
+ createElement(MessageHeader, { ...base, subject: undefined }),
33
+ );
34
+ assert.match(html, /\(No subject\)/);
35
+ });
36
+
37
+ it("renders the category badge and trust indicator", () => {
38
+ const html = renderToString(
39
+ createElement(MessageHeader, {
40
+ ...base,
41
+ category: "newsletter",
42
+ senderTrust: "vip",
43
+ }),
44
+ );
45
+ assert.match(html, /aria-label="Category: newsletter"/);
46
+ assert.match(html, /aria-label="VIP sender"/);
47
+ });
48
+
49
+ it("renders an actions slot when provided", () => {
50
+ const html = renderToString(
51
+ createElement(MessageHeader, {
52
+ ...base,
53
+ actions: createElement("button", { type: "button" }, "Menu"),
54
+ }),
55
+ );
56
+ assert.match(html, /Menu/);
57
+ });
58
+ });
@@ -0,0 +1,79 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Menu } from "lucide-react";
3
+ import type { EnvelopeAddress } from "./address-display.js";
4
+ import { MessageHeader } from "./message-header.js";
5
+
6
+ const meta: Meta<typeof MessageHeader> = {
7
+ title: "Mail/MessageHeader",
8
+ component: MessageHeader,
9
+ parameters: { layout: "fullscreen" },
10
+ };
11
+ export default meta;
12
+
13
+ type Story = StoryObj<typeof MessageHeader>;
14
+
15
+ const named = (name: string, email: string): EnvelopeAddress => ({
16
+ displayName: name,
17
+ normalizedEmail: email,
18
+ });
19
+
20
+ const trustedFrom: EnvelopeAddress = {
21
+ ...named("Ada Lovelace", "ada@example.com"),
22
+ flags: { trusted: { value: true } },
23
+ };
24
+
25
+ const date = "Mon, 23 Jun 2026, 14:00";
26
+
27
+ export const TrustedSender: Story = {
28
+ args: {
29
+ subject: "Quarterly numbers are in",
30
+ from: [trustedFrom],
31
+ to: [named("The team", "team@example.com")],
32
+ date,
33
+ senderTrust: "wellknown",
34
+ },
35
+ };
36
+
37
+ export const NewSenderNewsletter: Story = {
38
+ name: "New sender + newsletter",
39
+ args: {
40
+ subject: "Welcome to the weekly digest",
41
+ from: [named("Digest", "hello@digest.example.com")],
42
+ to: [{ normalizedEmail: "you@example.com" }],
43
+ date,
44
+ category: "newsletter",
45
+ senderTrust: "unknown",
46
+ },
47
+ };
48
+
49
+ export const VipWithManyRecipients: Story = {
50
+ name: "VIP + many recipients",
51
+ args: {
52
+ subject: "Board meeting follow-up",
53
+ from: [{ ...named("Grace Hopper", "grace@example.com") }],
54
+ to: [
55
+ named("Alan Turing", "alan@example.com"),
56
+ named("Katherine Johnson", "katherine@example.com"),
57
+ named("Dorothy Vaughan", "dorothy@example.com"),
58
+ named("Mary Jackson", "mary@example.com"),
59
+ ],
60
+ cc: [named("Margaret Hamilton", "margaret@example.com")],
61
+ date,
62
+ senderTrust: "vip",
63
+ },
64
+ };
65
+
66
+ export const NoSubjectWithActions: Story = {
67
+ name: "No subject + actions slot",
68
+ args: {
69
+ from: [named("Someone", "someone@example.com")],
70
+ to: [{ normalizedEmail: "you@example.com" }],
71
+ date,
72
+ senderTrust: "wellknown",
73
+ actions: (
74
+ <button type="button" aria-label="Menu" className="text-fg-muted">
75
+ <Menu className="size-5" />
76
+ </button>
77
+ ),
78
+ },
79
+ };
@@ -0,0 +1,59 @@
1
+ import type { ReactNode } from "react";
2
+ import { AddressList, type EnvelopeAddress } from "./address-display.js";
3
+ import { CategoryBadge, type MessageCategory } from "./category-badge.js";
4
+ import {
5
+ type SenderTrust,
6
+ SenderTrustIndicator,
7
+ } from "./sender-trust-indicator.js";
8
+
9
+ export interface MessageHeaderProps {
10
+ subject?: string;
11
+ from: EnvelopeAddress[];
12
+ to: EnvelopeAddress[];
13
+ cc?: EnvelopeAddress[];
14
+ /** Pre-formatted, human-readable date. The consumer owns formatting. */
15
+ date: string;
16
+ category?: MessageCategory;
17
+ senderTrust: SenderTrust;
18
+ /**
19
+ * Optional slot for surfaces (hamburger menu, etc) rendered inline on the
20
+ * right of the subject line. Kept generic so the header doesn't need to
21
+ * know what action set is in play.
22
+ */
23
+ actions?: ReactNode;
24
+ }
25
+
26
+ export const MessageHeader = ({
27
+ subject,
28
+ from,
29
+ to,
30
+ cc = [],
31
+ date,
32
+ category,
33
+ senderTrust,
34
+ actions,
35
+ }: MessageHeaderProps) => {
36
+ return (
37
+ <div className="border-b border-line p-4">
38
+ <div className="flex items-start justify-between gap-2 mb-3">
39
+ <div className="flex items-center gap-2 min-w-0 flex-1">
40
+ <h1 className="text-xl font-semibold truncate">
41
+ {subject || "(No subject)"}
42
+ </h1>
43
+ <CategoryBadge category={category} size="md" />
44
+ <SenderTrustIndicator senderTrust={senderTrust} size="md" />
45
+ </div>
46
+ {actions && <div className="shrink-0">{actions}</div>}
47
+ </div>
48
+ <div className="space-y-1">
49
+ <AddressList label="From" addresses={from} showTrustedBadge />
50
+ <AddressList label="To" addresses={to} />
51
+ {cc.length > 0 && <AddressList label="Cc" addresses={cc} />}
52
+ <div className="flex gap-2 text-sm">
53
+ <span className="text-fg-muted shrink-0 w-12">Date:</span>
54
+ <span className="text-fg">{date}</span>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ );
59
+ };
@@ -0,0 +1,136 @@
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 type { ThreadSection } from "./app-shell-types.js";
6
+ import { MessageListPane } from "./message-list-pane.js";
7
+
8
+ const sections: ThreadSection[] = [
9
+ {
10
+ id: "today",
11
+ label: "Today",
12
+ threads: [
13
+ {
14
+ id: "t1",
15
+ accountId: "a1",
16
+ fromName: "Alex Rivera",
17
+ fromEmail: "alex@example.com",
18
+ subject: "Q3 planning notes",
19
+ snippet: "Here are the notes from today's planning session.",
20
+ timeLabel: "9:42",
21
+ },
22
+ {
23
+ id: "t2",
24
+ accountId: "a1",
25
+ fromName: "Acme Billing",
26
+ fromEmail: "billing@acme.com",
27
+ subject: "Your invoice is ready",
28
+ snippet: "Invoice #1042 is available to view.",
29
+ timeLabel: "8:15",
30
+ },
31
+ ],
32
+ },
33
+ ];
34
+
35
+ const baseProps = {
36
+ listTitle: "Inbox",
37
+ sections,
38
+ flatList: true,
39
+ onSelectThread: () => undefined,
40
+ onSelectBriefCategory: () => undefined,
41
+ };
42
+
43
+ describe("MessageListPane", () => {
44
+ it("renders the list title and the rows", () => {
45
+ const html = renderToString(
46
+ createElement(MessageListPane, { ...baseProps, isDesktop: true }),
47
+ );
48
+ assert.match(html, /Inbox/);
49
+ assert.match(html, /Q3 planning notes/);
50
+ assert.match(html, /Your invoice is ready/);
51
+ });
52
+
53
+ it("shows the pull-to-refresh affordance on the narrow touch list", () => {
54
+ const html = renderToString(
55
+ createElement(MessageListPane, {
56
+ ...baseProps,
57
+ isDesktop: false,
58
+ listState: "ready",
59
+ }),
60
+ );
61
+ assert.match(html, /Pull to refresh/);
62
+ });
63
+
64
+ it("omits the pull-to-refresh affordance on the desktop list", () => {
65
+ const html = renderToString(
66
+ createElement(MessageListPane, { ...baseProps, isDesktop: true }),
67
+ );
68
+ assert.doesNotMatch(html, /Pull to refresh/);
69
+ });
70
+
71
+ it("renders the listBody slot instead of built-in rows when provided", () => {
72
+ const html = renderToString(
73
+ createElement(MessageListPane, {
74
+ ...baseProps,
75
+ isDesktop: true,
76
+ listBody: createElement(
77
+ "div",
78
+ { "data-testid": "custom-body" },
79
+ "virtualized rows here",
80
+ ),
81
+ }),
82
+ );
83
+ // Custom body is rendered
84
+ assert.match(html, /virtualized rows here/);
85
+ // Built-in rows are NOT rendered when listBody is provided
86
+ assert.doesNotMatch(html, /Q3 planning notes/);
87
+ });
88
+
89
+ it("honors the listBody slot on the touch path so mobile rows stay real anchors", () => {
90
+ const html = renderToString(
91
+ createElement(MessageListPane, {
92
+ ...baseProps,
93
+ isDesktop: false,
94
+ listState: "ready",
95
+ listBody: createElement(
96
+ "a",
97
+ { href: "/mail/inbox?selectedMessageId=t1" },
98
+ "Q3 planning notes",
99
+ ),
100
+ }),
101
+ );
102
+ // The consumer's anchor row is rendered on the mobile/touch path…
103
+ assert.match(html, /href="\/mail\/inbox\?selectedMessageId=t1"/);
104
+ // …and the built-in TouchListBody mock fallback is NOT substituted.
105
+ assert.doesNotMatch(html, /Pull to refresh/);
106
+ });
107
+
108
+ it("surfaces the specific error message and a report path in the error state", () => {
109
+ const html = renderToString(
110
+ createElement(MessageListPane, {
111
+ ...baseProps,
112
+ isDesktop: true,
113
+ listState: "error",
114
+ errorMessage: "IMAP connection timed out",
115
+ onRetry: () => undefined,
116
+ onReportError: () => undefined,
117
+ }),
118
+ );
119
+ assert.match(html, /IMAP connection timed out/);
120
+ assert.match(html, /Retry/);
121
+ assert.match(html, /Report a problem/);
122
+ });
123
+
124
+ it("renders the selectionBar slot instead of the pane header when provided", () => {
125
+ const html = renderToString(
126
+ createElement(MessageListPane, {
127
+ ...baseProps,
128
+ isDesktop: true,
129
+ selectionBar: createElement("div", null, "2 selected"),
130
+ }),
131
+ );
132
+ assert.match(html, /2 selected/);
133
+ // The mailbox title header is NOT rendered when selectionBar is provided
134
+ assert.doesNotMatch(html, /Inbox/);
135
+ });
136
+ });