@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,180 @@
1
+ import {
2
+ FolderInput,
3
+ Forward,
4
+ Reply,
5
+ ReplyAll,
6
+ Star,
7
+ Trash2,
8
+ } from "lucide-react";
9
+ import type { ReactNode } from "react";
10
+ import { cn } from "../lib/cn.js";
11
+ import { Button } from "./button.js";
12
+
13
+ /** The verbs the reading-pane toolbar exposes. No archive: Remit is IMAP-backed
14
+ * and has no archive verb — the IMAP-native equivalent is move-to-folder. */
15
+ export type MailAction =
16
+ | "reply"
17
+ | "replyAll"
18
+ | "forward"
19
+ | "delete"
20
+ | "move"
21
+ | "flag";
22
+
23
+ export interface MailActionToolbarProps {
24
+ /** Whether a thread is open. Drives the no-op-explain behaviour, never `disabled`. */
25
+ hasThread: boolean;
26
+ /**
27
+ * Fired when an action button is pressed with no thread open. The host
28
+ * surfaces a one-line inline explanation — never a toast, never a disabled
29
+ * button (`doc/rules/ux.md`: never disable a control).
30
+ */
31
+ onUnavailable?: (action: MailAction) => void;
32
+ /** A one-line inline notice rendered under the toolbar (e.g. "Open a message first"). */
33
+ unavailableHint?: ReactNode;
34
+ isStarred?: boolean;
35
+ /**
36
+ * Whether to render the triage cluster (move-to-trash / move / flag).
37
+ * Defaults to `true` for the desktop reading-pane toolbar. The mobile pane
38
+ * sets it `false` because its management bar already owns triage — rendering
39
+ * both would create duplicate accessible names.
40
+ */
41
+ showTriage?: boolean;
42
+ onReply?: () => void;
43
+ onReplyAll?: () => void;
44
+ onForward?: () => void;
45
+ onDelete?: () => void;
46
+ onToggleStar?: () => void;
47
+ /** Replaces the move button with a host-supplied trigger (e.g. the picker popover). */
48
+ moveSlot?: ReactNode;
49
+ onMove?: () => void;
50
+ replyTitle?: string;
51
+ replyAllTitle?: string;
52
+ forwardTitle?: string;
53
+ deleteTitle?: string;
54
+ flagTitle?: string;
55
+ /** Trailing content (search, compose, intelligence toggle, account menu). */
56
+ children?: ReactNode;
57
+ className?: string;
58
+ }
59
+
60
+ /**
61
+ * The reading-pane action toolbar, shared between the remit-ui AppShell
62
+ * reference and the live client. Buttons are always pressable: with no thread
63
+ * open they no-op and call `onUnavailable(action)` so the host can explain why,
64
+ * rather than greying out (the never-disable tenet — a `disabled` button gives
65
+ * the user no path to learn what it does or what they must do first).
66
+ */
67
+ export function MailActionToolbar({
68
+ hasThread,
69
+ onUnavailable,
70
+ unavailableHint,
71
+ isStarred,
72
+ showTriage = true,
73
+ onReply,
74
+ onReplyAll,
75
+ onForward,
76
+ onDelete,
77
+ onToggleStar,
78
+ moveSlot,
79
+ onMove,
80
+ replyTitle = "Reply (r)",
81
+ replyAllTitle = "Reply all (a)",
82
+ forwardTitle = "Forward (f)",
83
+ deleteTitle = "Move to Trash (#)",
84
+ flagTitle = "Flag (s)",
85
+ children,
86
+ className,
87
+ }: MailActionToolbarProps) {
88
+ const act = (action: MailAction, handler?: () => void) => () => {
89
+ if (!hasThread) {
90
+ onUnavailable?.(action);
91
+ return;
92
+ }
93
+ handler?.();
94
+ };
95
+
96
+ return (
97
+ <div className="relative">
98
+ <header
99
+ className={cn(
100
+ "flex h-pane-header shrink-0 items-center gap-1 border-b border-line bg-surface px-3",
101
+ className,
102
+ )}
103
+ >
104
+ <Button
105
+ variant="ghost"
106
+ size="sm"
107
+ icon={<Reply className="size-4" />}
108
+ title={replyTitle}
109
+ aria-label="Reply"
110
+ onClick={act("reply", onReply)}
111
+ />
112
+ <Button
113
+ variant="ghost"
114
+ size="sm"
115
+ icon={<ReplyAll className="size-4" />}
116
+ title={replyAllTitle}
117
+ aria-label="Reply all"
118
+ onClick={act("replyAll", onReplyAll)}
119
+ />
120
+ <Button
121
+ variant="ghost"
122
+ size="sm"
123
+ icon={<Forward className="size-4" />}
124
+ title={forwardTitle}
125
+ aria-label="Forward"
126
+ onClick={act("forward", onForward)}
127
+ />
128
+ {showTriage && (
129
+ <>
130
+ <span className="mx-1 h-4 w-px bg-line" aria-hidden />
131
+ <Button
132
+ variant="ghost"
133
+ size="sm"
134
+ icon={<Trash2 className="size-4" />}
135
+ title={deleteTitle}
136
+ aria-label="Move to Trash"
137
+ onClick={act("delete", onDelete)}
138
+ />
139
+ {moveSlot ?? (
140
+ <Button
141
+ variant="ghost"
142
+ size="sm"
143
+ icon={<FolderInput className="size-4" />}
144
+ title="Move to mailbox"
145
+ aria-label="Move to mailbox"
146
+ onClick={act("move", onMove)}
147
+ />
148
+ )}
149
+ <Button
150
+ variant="ghost"
151
+ size="sm"
152
+ icon={
153
+ <Star
154
+ className={cn(
155
+ "size-4",
156
+ isStarred && "fill-warning text-warning",
157
+ )}
158
+ />
159
+ }
160
+ title={flagTitle}
161
+ aria-label="Flag"
162
+ onClick={act("flag", onToggleStar)}
163
+ />
164
+ </>
165
+ )}
166
+ <div className="flex-1" />
167
+ {children}
168
+ </header>
169
+ {!hasThread && unavailableHint && (
170
+ // biome-ignore lint/a11y/useSemanticElements: <p> with role="status" preserves block layout; <output> is inline
171
+ <p
172
+ role="status"
173
+ className="border-b border-line bg-surface-sunken px-3 py-1 text-2xs text-fg-subtle"
174
+ >
175
+ {unavailableHint}
176
+ </p>
177
+ )}
178
+ </div>
179
+ );
180
+ }
@@ -0,0 +1,69 @@
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 { MailHeader, type MailHeaderProps } from "./mail-header.js";
6
+
7
+ function render(overrides: Partial<MailHeaderProps> = {}): string {
8
+ return renderToString(
9
+ createElement(MailHeader, {
10
+ title: "Daily brief",
11
+ unreadCount: 15338,
12
+ isDesktop: false,
13
+ searchValue: "",
14
+ onSearchChange: () => undefined,
15
+ searchOpen: false,
16
+ onSearchOpenChange: () => undefined,
17
+ ...overrides,
18
+ }),
19
+ );
20
+ }
21
+
22
+ describe("MailHeader", () => {
23
+ it("renders the title and a formatted unread count", () => {
24
+ const html = render();
25
+ assert.match(html, /Daily brief/);
26
+ assert.match(html, /15,338 unread/);
27
+ });
28
+
29
+ it("renders the menu (hamburger) control", () => {
30
+ assert.match(render(), /aria-label="Menu"/);
31
+ assert.match(render({ isDesktop: true }), /aria-label="Menu"/);
32
+ });
33
+
34
+ it("does not render an account chip row (accounts live in the filter / nav)", () => {
35
+ assert.doesNotMatch(render(), /aria-label="Filters"/);
36
+ });
37
+
38
+ it("shows the search magnifier and hides the input when collapsed", () => {
39
+ const html = render({ searchOpen: false });
40
+ assert.match(html, /aria-label="Search"/);
41
+ assert.doesNotMatch(html, /aria-label="Search mail"/);
42
+ });
43
+
44
+ it("expands the search input and offers a close control when open", () => {
45
+ const html = render({ searchOpen: true });
46
+ assert.match(html, /aria-label="Search mail"/);
47
+ assert.match(html, /aria-label="Close search"/);
48
+ });
49
+
50
+ it("offers a single X when open with a query — close, not a separate inline clear", () => {
51
+ const html = render({ searchOpen: true, searchValue: "invoice" });
52
+ assert.match(html, /aria-label="Close search"/);
53
+ assert.doesNotMatch(html, /aria-label="Clear search"/);
54
+ });
55
+
56
+ it("expands the search bar when a query is active even if not explicitly opened", () => {
57
+ const html = render({ searchOpen: false, searchValue: "invoice" });
58
+ assert.match(html, /aria-label="Search mail"/);
59
+ assert.match(html, /aria-label="Close search"/);
60
+ assert.doesNotMatch(html, /aria-label="Search"/);
61
+ });
62
+
63
+ it("renders the search bar inline on desktop, keeping its own clear", () => {
64
+ const html = render({ isDesktop: true, searchValue: "invoice" });
65
+ assert.match(html, /aria-label="Search mail"/);
66
+ assert.match(html, /aria-label="Clear search"/);
67
+ assert.match(html, /Daily brief/);
68
+ });
69
+ });
@@ -0,0 +1,233 @@
1
+ import type { Decorator, Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import {
4
+ briefFilterConfig,
5
+ type FilterAccount,
6
+ type FilterPreset,
7
+ inboxFilterConfig,
8
+ } from "../filter-presets.js";
9
+ import { FilterSheet } from "./filter-sheet.js";
10
+ import { MailHeader } from "./mail-header.js";
11
+
12
+ const accounts: FilterAccount[] = [
13
+ { id: "all", label: "All", active: true },
14
+ { id: "personal", label: "Personal", count: 9 },
15
+ { id: "work", label: "Work", count: 14 },
16
+ ];
17
+
18
+ const phoneFrame: Decorator = (Story) => (
19
+ <div
20
+ className="overflow-hidden border border-line bg-canvas"
21
+ style={{ width: 390, height: 720 }}
22
+ >
23
+ <Story />
24
+ </div>
25
+ );
26
+
27
+ function MockList() {
28
+ const rows = [
29
+ ["Priya Natarajan", "Q3 roadmap review — agenda + pre-read"],
30
+ ["Mei Tan", "Sunday lunch?"],
31
+ ["Linear", "Your receipt from Linear"],
32
+ ["The Pragmatic Engineer", "Platform teams that scale"],
33
+ ["Coolblue", "Tot 30% korting op monitoren"],
34
+ ["GitHub", "[remit] PR #418: fix thread reconstruction"],
35
+ ["Strava", "Jord gave you kudos on your morning ride"],
36
+ ["Airbnb", "Your reservation in Lisbon is confirmed"],
37
+ ];
38
+ return (
39
+ <ul className="divide-y divide-line">
40
+ {rows.map(([from, subject]) => (
41
+ <li key={subject} className="px-row-inset py-3">
42
+ <div className="text-sm font-medium text-fg">{from}</div>
43
+ <div className="truncate text-xs text-fg-muted">{subject}</div>
44
+ </li>
45
+ ))}
46
+ </ul>
47
+ );
48
+ }
49
+
50
+ /**
51
+ * The shared mail view: the MailHeader top row, then the FilterSheet bar (its
52
+ * caret opens the fuller filter over the list). The preset decides which groups
53
+ * the filter offers — brief vs inbox.
54
+ */
55
+ function MailScreen({
56
+ title,
57
+ unreadCount,
58
+ preset,
59
+ initialExpanded = false,
60
+ initialSearchOpen = false,
61
+ initialSearchValue = "",
62
+ }: {
63
+ title: string;
64
+ unreadCount: number;
65
+ preset: FilterPreset;
66
+ initialExpanded?: boolean;
67
+ initialSearchOpen?: boolean;
68
+ initialSearchValue?: string;
69
+ }) {
70
+ const [searchValue, setSearchValue] = useState(initialSearchValue);
71
+ const [searchOpen, setSearchOpen] = useState(initialSearchOpen);
72
+ const [expanded, setExpanded] = useState(initialExpanded);
73
+ const [category, setCategory] = useState("all");
74
+ const [activeFilters, setActiveFilters] = useState<Set<string>>(new Set());
75
+ const [source, setSource] = useState("all");
76
+
77
+ const sources = preset.sources?.map((s) => ({
78
+ ...s,
79
+ active: s.id === source,
80
+ }));
81
+
82
+ return (
83
+ <div className="flex h-full flex-col">
84
+ <MailHeader
85
+ title={title}
86
+ unreadCount={unreadCount}
87
+ isDesktop={false}
88
+ onMenuClick={() => undefined}
89
+ searchValue={searchValue}
90
+ onSearchChange={setSearchValue}
91
+ searchOpen={searchOpen}
92
+ onSearchOpenChange={setSearchOpen}
93
+ />
94
+ <div className="min-h-0 flex-1">
95
+ <FilterSheet
96
+ categories={preset.categories}
97
+ filters={preset.filters}
98
+ sources={sources}
99
+ selectedCategory={category}
100
+ activeFilters={activeFilters}
101
+ expanded={expanded}
102
+ onExpandedChange={setExpanded}
103
+ onSelectCategory={setCategory}
104
+ onSelectSource={setSource}
105
+ onToggleFilter={(id) =>
106
+ setActiveFilters((prev) => {
107
+ const next = new Set(prev);
108
+ if (next.has(id)) next.delete(id);
109
+ else next.add(id);
110
+ return next;
111
+ })
112
+ }
113
+ onClear={() => {
114
+ setCategory("all");
115
+ setActiveFilters(new Set());
116
+ }}
117
+ >
118
+ <MockList />
119
+ </FilterSheet>
120
+ </div>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ const meta: Meta<typeof MailHeader> = {
126
+ title: "Screens/Kit/MailHeader",
127
+ component: MailHeader,
128
+ parameters: { layout: "centered" },
129
+ decorators: [phoneFrame],
130
+ };
131
+ export default meta;
132
+
133
+ type Story = StoryObj<typeof MailHeader>;
134
+
135
+ /** Brief, filter collapsed: header + the FilterSheet bar with its caret. */
136
+ export const BriefFilterCollapsed: Story = {
137
+ render: () => (
138
+ <MailScreen
139
+ title="Daily brief"
140
+ unreadCount={15338}
141
+ preset={briefFilterConfig(accounts)}
142
+ />
143
+ ),
144
+ };
145
+
146
+ /**
147
+ * Brief, filter expanded — categories + Unread/Has attachment/From contacts/Today,
148
+ * plus the accounts group because more than one account feeds the aggregate brief.
149
+ */
150
+ export const BriefFilterExpanded: Story = {
151
+ render: () => (
152
+ <MailScreen
153
+ title="Daily brief"
154
+ unreadCount={15338}
155
+ preset={briefFilterConfig(accounts)}
156
+ initialExpanded
157
+ />
158
+ ),
159
+ };
160
+
161
+ /**
162
+ * Single account: the brief filter drops the accounts group entirely (one
163
+ * account is meaningless to segment) — categories + the brief chip set only.
164
+ */
165
+ export const BriefFilterSingleAccount: Story = {
166
+ render: () => (
167
+ <MailScreen
168
+ title="Daily brief"
169
+ unreadCount={42}
170
+ preset={briefFilterConfig(accounts.slice(0, 1))}
171
+ initialExpanded
172
+ />
173
+ ),
174
+ };
175
+
176
+ /**
177
+ * Inbox, filter expanded — categories + Unread/Flagged/Has attachment. No
178
+ * accounts group: an inbox is already scoped to one account.
179
+ */
180
+ export const InboxFilterExpanded: Story = {
181
+ render: () => (
182
+ <MailScreen
183
+ title="Inbox"
184
+ unreadCount={42}
185
+ preset={inboxFilterConfig()}
186
+ initialExpanded
187
+ />
188
+ ),
189
+ };
190
+
191
+ /** Mobile search collapsed to a magnifier in the header top row. */
192
+ export const MobileSearchCollapsed: Story = {
193
+ render: () => (
194
+ <MailScreen
195
+ title="Daily brief"
196
+ unreadCount={15338}
197
+ preset={briefFilterConfig(accounts)}
198
+ />
199
+ ),
200
+ };
201
+
202
+ /**
203
+ * Mobile search expanded over the title. A single X owns the dismiss: it clears
204
+ * the query AND closes the search (the SearchBar's own inline clear is suppressed
205
+ * here, so there is exactly one X).
206
+ */
207
+ export const MobileSearchExpanded: Story = {
208
+ render: () => (
209
+ <MailScreen
210
+ title="Daily brief"
211
+ unreadCount={15338}
212
+ preset={briefFilterConfig(accounts)}
213
+ initialSearchOpen
214
+ initialSearchValue="invoice"
215
+ />
216
+ ),
217
+ };
218
+
219
+ /**
220
+ * An active query expands the bar on its own — even when search was never
221
+ * explicitly opened (e.g. returning from a result via Back). The query is
222
+ * visible, not hidden behind the collapsed magnifier.
223
+ */
224
+ export const MobileSearchActiveQueryNotOpened: Story = {
225
+ render: () => (
226
+ <MailScreen
227
+ title="Daily brief"
228
+ unreadCount={15338}
229
+ preset={briefFilterConfig(accounts)}
230
+ initialSearchValue="invoice"
231
+ />
232
+ ),
233
+ };
@@ -0,0 +1,123 @@
1
+ import { Menu, Search, X } from "lucide-react";
2
+ import { Button } from "./button.js";
3
+ import { SearchBar } from "./search-bar.js";
4
+
5
+ export interface MailHeaderProps {
6
+ /** Top-row title — the view name, e.g. "Daily brief" or "Inbox". */
7
+ title: string;
8
+ /** Unread count shown beside the title, e.g. 15338 → "15,338 unread". */
9
+ unreadCount: number;
10
+ /**
11
+ * Container-derived ≥1024 desktop tier. Desktop renders the search bar
12
+ * inline; below it search collapses to a magnifier that expands over the
13
+ * title (mirrors the live mobile header).
14
+ */
15
+ isDesktop: boolean;
16
+ /** Opens the app navigation drawer (hamburger). */
17
+ onMenuClick?: () => void;
18
+ searchValue: string;
19
+ onSearchChange: (value: string) => void;
20
+ /** Full clear (X). Falls back to clearing the value. */
21
+ onSearchClear?: () => void;
22
+ /** Mobile only: whether the inline search is expanded over the title. */
23
+ searchOpen: boolean;
24
+ onSearchOpenChange: (open: boolean) => void;
25
+ }
26
+
27
+ /**
28
+ * The shared mail header used across views — the daily brief and each inbox.
29
+ * Just the top row: hamburger + title + unread count + search. The view's
30
+ * filter (categories, attributes, and — for the aggregate brief — accounts)
31
+ * lives in the FilterSheet bar the consumer renders directly below this header;
32
+ * fast account switching is the nav sidebar, so there is no account chip row
33
+ * here. Search adapts to the tier — inline on desktop, a magnifier that expands
34
+ * the bar over the title on mobile. Open state is controlled so stories and the
35
+ * route can drive it.
36
+ */
37
+ export function MailHeader({
38
+ title,
39
+ unreadCount,
40
+ isDesktop,
41
+ onMenuClick,
42
+ searchValue,
43
+ onSearchChange,
44
+ onSearchClear,
45
+ searchOpen,
46
+ onSearchOpenChange,
47
+ }: MailHeaderProps) {
48
+ const unreadLabel = `${unreadCount.toLocaleString()} unread`;
49
+ const clearSearch = onSearchClear ?? (() => onSearchChange(""));
50
+ const clearAndClose = () => {
51
+ clearSearch();
52
+ onSearchOpenChange(false);
53
+ };
54
+
55
+ const renderSearchBar = (showClearButton: boolean) => (
56
+ <SearchBar
57
+ value={searchValue}
58
+ onChange={onSearchChange}
59
+ onClear={clearSearch}
60
+ globalFocusKey={false}
61
+ showClearButton={showClearButton}
62
+ />
63
+ );
64
+
65
+ const menuButton = (
66
+ <Button
67
+ variant="ghost"
68
+ icon={<Menu className="size-5" />}
69
+ onClick={onMenuClick}
70
+ aria-label="Menu"
71
+ className="min-h-11 min-w-11 shrink-0 px-0"
72
+ />
73
+ );
74
+
75
+ return (
76
+ <header className="flex shrink-0 flex-col bg-canvas">
77
+ <div className="flex h-12 items-center gap-2 px-row-inset">
78
+ {isDesktop ? (
79
+ <>
80
+ {menuButton}
81
+ <h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
82
+ {title}
83
+ </h1>
84
+ <span className="shrink-0 text-2xs text-fg-subtle">
85
+ {unreadLabel}
86
+ </span>
87
+ <div className="w-64 max-w-[40%] shrink-0">
88
+ {renderSearchBar(true)}
89
+ </div>
90
+ </>
91
+ ) : searchOpen || searchValue.trim().length > 0 ? (
92
+ <div className="flex flex-1 items-center gap-1">
93
+ <div className="flex-1">{renderSearchBar(false)}</div>
94
+ <Button
95
+ variant="ghost"
96
+ icon={<X className="size-5" />}
97
+ onClick={clearAndClose}
98
+ aria-label="Close search"
99
+ className="min-h-11 min-w-11 shrink-0 px-0"
100
+ />
101
+ </div>
102
+ ) : (
103
+ <>
104
+ {menuButton}
105
+ <h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
106
+ {title}
107
+ </h1>
108
+ <span className="shrink-0 text-2xs text-fg-subtle">
109
+ {unreadLabel}
110
+ </span>
111
+ <Button
112
+ variant="ghost"
113
+ icon={<Search className="size-5" />}
114
+ onClick={() => onSearchOpenChange(true)}
115
+ aria-label="Search"
116
+ className="min-h-11 min-w-11 shrink-0 px-0"
117
+ />
118
+ </>
119
+ )}
120
+ </div>
121
+ </header>
122
+ );
123
+ }
@@ -0,0 +1,59 @@
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 { MessageBodyView } from "./message-body-view.js";
6
+
7
+ /**
8
+ * `MessageBodyView` sanitizes via DOMPurify, which needs a DOM. Under SSR /
9
+ * plain-node (no DOM) the component cannot sanitize, so it never emits the email
10
+ * HTML — that guard is the security contract: unsanitizable mail is not
11
+ * rendered. These tests cover the DOM-free paths (empty + plain-text fallback +
12
+ * the no-DOM HTML guard); the sanitize/classify behavior is pinned by the
13
+ * sanitizer + render-treatment unit tests, and the live rendering by the
14
+ * Storybook stories.
15
+ */
16
+ describe("MessageBodyView", () => {
17
+ it("renders the empty state when there is no html or text", () => {
18
+ const html = renderToString(createElement(MessageBodyView, {}));
19
+ assert.match(html, /This message has no body content\./);
20
+ });
21
+
22
+ it("renders plain text in a pre block (no sanitizer needed)", () => {
23
+ const html = renderToString(
24
+ createElement(MessageBodyView, { text: "Just a plain note." }),
25
+ );
26
+ assert.match(html, /Just a plain note\./);
27
+ assert.match(html, /email-text/);
28
+ });
29
+
30
+ it("does not emit raw email HTML without a DOM to sanitize it", () => {
31
+ // No DOM ⇒ cannot DOMPurify ⇒ the component must NOT paint the HTML.
32
+ const html = renderToString(
33
+ createElement(MessageBodyView, {
34
+ html: "<p>secret newsletter body</p>",
35
+ category: "newsletter",
36
+ }),
37
+ );
38
+ assert.doesNotMatch(html, /secret newsletter body/);
39
+ });
40
+
41
+ it("falls back to text when html cannot be sanitized (no DOM)", () => {
42
+ const html = renderToString(
43
+ createElement(MessageBodyView, {
44
+ html: "<p>html body</p>",
45
+ text: "plain fallback",
46
+ }),
47
+ );
48
+ assert.doesNotMatch(html, /html body/);
49
+ assert.match(html, /plain fallback/);
50
+ });
51
+
52
+ it("applies the message-body wrapper class", () => {
53
+ const html = renderToString(
54
+ createElement(MessageBodyView, { text: "x", className: "px-4" }),
55
+ );
56
+ assert.match(html, /message-body/);
57
+ assert.match(html, /px-4/);
58
+ });
59
+ });