@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,175 @@
1
+ import {
2
+ Forward,
3
+ Mail,
4
+ MailOpen,
5
+ Reply,
6
+ ReplyAll,
7
+ Star,
8
+ Trash2,
9
+ } from "lucide-react";
10
+ import type { ReactNode } from "react";
11
+ import { cn } from "../lib/cn.js";
12
+ import { Button } from "./button.js";
13
+ import { PopoverMenu, type PopoverMenuItem } from "./popover-menu.js";
14
+
15
+ export type MobileMessageAction =
16
+ | "reply"
17
+ | "replyAll"
18
+ | "forward"
19
+ | "star"
20
+ | "delete"
21
+ | "toggleRead";
22
+
23
+ export interface MobileMessageActionBarProps {
24
+ /** Whether a message is open. Gates the verbs (never disables). */
25
+ hasThread: boolean;
26
+ /* ---- Reply verbs ----
27
+ * Per-message: replying targets the message this bar belongs to. Omit a
28
+ * handler to drop that verb. */
29
+ onReply?: () => void;
30
+ onReplyAll?: () => void;
31
+ onForward?: () => void;
32
+ isStarred?: boolean;
33
+ onToggleStar?: () => void;
34
+ onDelete?: () => void;
35
+ isRead?: boolean;
36
+ onToggleRead?: () => void;
37
+ /**
38
+ * Move-to-folder trigger. The kit owns the bar layout; the app supplies the
39
+ * picker trigger so the kit needn't depend on folder data. Sized to match the
40
+ * bar's touch buttons by the caller.
41
+ */
42
+ moveSlot?: ReactNode;
43
+ /** Extra overflow rows appended after mark read/unread. */
44
+ overflowItems?: PopoverMenuItem[];
45
+ /** Fired when a verb is pressed with no message open, so the host can explain. */
46
+ onUnavailable?: (action: MobileMessageAction) => void;
47
+ /** One-line inline notice rendered under the bar when no message is open. */
48
+ unavailableHint?: ReactNode;
49
+ className?: string;
50
+ }
51
+
52
+ const TOUCH = "min-h-11 min-w-11 px-0";
53
+
54
+ /**
55
+ * The per-message action bar for the narrow-width (single-pane) reading view.
56
+ * It belongs to one expanded message and owns that message's verbs — reply /
57
+ * reply-all / forward on the left, then star, move, delete and the overflow
58
+ * (mark read/unread) on the right — so reply targets this message and nothing
59
+ * repeats it. Intelligence is not here: it lives once in the top app bar and
60
+ * reflects the active message. No archive either — Remit is IMAP-backed and
61
+ * IMAP has no archive concept. Built from the kit `Button`, `PopoverMenu` and a
62
+ * caller-supplied `moveSlot`; buttons stay pressable with no message open and
63
+ * call `onUnavailable` rather than greying out.
64
+ */
65
+ export function MobileMessageActionBar({
66
+ hasThread,
67
+ onReply,
68
+ onReplyAll,
69
+ onForward,
70
+ isStarred,
71
+ onToggleStar,
72
+ onDelete,
73
+ isRead,
74
+ onToggleRead,
75
+ moveSlot,
76
+ overflowItems = [],
77
+ onUnavailable,
78
+ unavailableHint,
79
+ className,
80
+ }: MobileMessageActionBarProps) {
81
+ const act = (action: MobileMessageAction, handler?: () => void) => () => {
82
+ if (!hasThread) {
83
+ onUnavailable?.(action);
84
+ return;
85
+ }
86
+ handler?.();
87
+ };
88
+
89
+ const readItem: PopoverMenuItem[] = onToggleRead
90
+ ? [
91
+ {
92
+ key: "toggle-read",
93
+ label: isRead ? "Mark as unread" : "Mark as read",
94
+ icon: isRead ? (
95
+ <Mail className="size-4" />
96
+ ) : (
97
+ <MailOpen className="size-4" />
98
+ ),
99
+ onSelect: act("toggleRead", onToggleRead),
100
+ },
101
+ ]
102
+ : [];
103
+
104
+ return (
105
+ <div className={cn("relative", className)}>
106
+ <div className="flex h-12 shrink-0 items-center gap-0.5 border-y border-line bg-canvas px-1">
107
+ <Button
108
+ variant="ghost"
109
+ size="sm"
110
+ icon={<Reply className="size-5" />}
111
+ onClick={act("reply", onReply)}
112
+ aria-label="Reply"
113
+ title="Reply"
114
+ className={TOUCH}
115
+ />
116
+ <Button
117
+ variant="ghost"
118
+ size="sm"
119
+ icon={<ReplyAll className="size-5" />}
120
+ onClick={act("replyAll", onReplyAll)}
121
+ aria-label="Reply all"
122
+ title="Reply all"
123
+ className={TOUCH}
124
+ />
125
+ <Button
126
+ variant="ghost"
127
+ size="sm"
128
+ icon={<Forward className="size-5" />}
129
+ onClick={act("forward", onForward)}
130
+ aria-label="Forward"
131
+ title="Forward"
132
+ className={TOUCH}
133
+ />
134
+ <div className="flex-1" />
135
+ <Button
136
+ variant="ghost"
137
+ size="sm"
138
+ icon={
139
+ <Star
140
+ className={cn("size-5", isStarred && "fill-warning text-warning")}
141
+ />
142
+ }
143
+ onClick={act("star", onToggleStar)}
144
+ aria-label={isStarred ? "Remove flag" : "Flag"}
145
+ aria-pressed={isStarred}
146
+ title={isStarred ? "Remove flag" : "Flag"}
147
+ className={TOUCH}
148
+ />
149
+ {moveSlot}
150
+ <Button
151
+ variant="ghost"
152
+ size="sm"
153
+ icon={<Trash2 className="size-5" />}
154
+ onClick={act("delete", onDelete)}
155
+ aria-label="Move to Trash"
156
+ title="Move to Trash"
157
+ className={TOUCH}
158
+ />
159
+ <PopoverMenu
160
+ triggerLabel="More actions"
161
+ items={[...readItem, ...overflowItems]}
162
+ />
163
+ </div>
164
+ {!hasThread && unavailableHint && (
165
+ // biome-ignore lint/a11y/useSemanticElements: <p> with role="status" preserves block layout; <output> is inline
166
+ <p
167
+ role="status"
168
+ className="border-b border-line bg-surface-sunken px-3 py-1 text-2xs text-fg-subtle"
169
+ >
170
+ {unavailableHint}
171
+ </p>
172
+ )}
173
+ </div>
174
+ );
175
+ }
@@ -0,0 +1,102 @@
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 { ThreadData } from "./app-shell-types.js";
6
+ import type { IntelligenceData } from "./intelligence-panel.js";
7
+ import { MobileReadingPane } from "./mobile-reading-pane.js";
8
+
9
+ const thread: ThreadData = {
10
+ subject: "Q3 planning notes",
11
+ messages: [
12
+ {
13
+ id: "m1",
14
+ fromName: "Alex Rivera",
15
+ fromEmail: "alex@example.com",
16
+ toLabel: "you",
17
+ dateLabel: "Mon 9:42",
18
+ snippet: "Here are the notes.",
19
+ bodyHtml: "<p>Here are the notes.</p>",
20
+ },
21
+ {
22
+ id: "m2",
23
+ fromName: "Jamie Chen",
24
+ fromEmail: "jamie@example.com",
25
+ toLabel: "you",
26
+ dateLabel: "Mon 11:15",
27
+ snippet: "Thanks.",
28
+ bodyHtml: "<p>Thanks.</p>",
29
+ },
30
+ {
31
+ id: "m3",
32
+ fromName: "Alex Rivera",
33
+ fromEmail: "alex@example.com",
34
+ toLabel: "you",
35
+ dateLabel: "Tue 08:03",
36
+ snippet: "One more thing.",
37
+ bodyHtml: "<p>One more thing.</p>",
38
+ expanded: true,
39
+ },
40
+ ],
41
+ };
42
+
43
+ const intelligence: IntelligenceData = {
44
+ sender: {
45
+ name: "Alex Rivera",
46
+ email: "alex@example.com",
47
+ trust: "wellknown",
48
+ firstSeenLabel: "Jan 2025",
49
+ },
50
+ authenticity: {
51
+ verdict: "aligned",
52
+ fromDomain: "example.com",
53
+ summary: "Verified.",
54
+ },
55
+ category: { value: "Personal" },
56
+ similar: [],
57
+ };
58
+
59
+ const countMatches = (html: string, needle: string) =>
60
+ html.split(needle).length - 1;
61
+
62
+ describe("MobileReadingPane", () => {
63
+ it("renders the top bar with back, subject and the intelligence toggle", () => {
64
+ const html = renderToString(
65
+ createElement(MobileReadingPane, {
66
+ thread,
67
+ intelligence,
68
+ onBack: () => undefined,
69
+ }),
70
+ );
71
+ assert.match(html, /aria-label="Back to messages"/);
72
+ assert.match(html, /Q3 planning notes/);
73
+ assert.match(html, /aria-label="Show intelligence panel"/);
74
+ });
75
+
76
+ it("omits the intelligence toggle when no intelligence is given", () => {
77
+ const html = renderToString(
78
+ createElement(MobileReadingPane, { thread, onBack: () => undefined }),
79
+ );
80
+ assert.doesNotMatch(html, /aria-label="Show intelligence panel"/);
81
+ });
82
+
83
+ it("shows exactly one per-message action bar — the expanded message's", () => {
84
+ const html = renderToString(
85
+ createElement(MobileReadingPane, { thread, onBack: () => undefined }),
86
+ );
87
+ // One expanded message ⇒ one Reply / Flag / Trash cluster; the two
88
+ // collapsed rows carry no bar.
89
+ assert.equal(countMatches(html, 'aria-label="Reply"'), 1);
90
+ assert.equal(countMatches(html, 'aria-label="Flag"'), 1);
91
+ assert.equal(countMatches(html, 'aria-label="Move to Trash"'), 1);
92
+ });
93
+
94
+ it("has no thread-level reply footer (reply belongs to the message)", () => {
95
+ // The desktop/legacy footer carries a "Reply (r)" title; the per-message
96
+ // bar titles are plain "Reply". Assert the footer affordance is absent.
97
+ const html = renderToString(
98
+ createElement(MobileReadingPane, { thread, onBack: () => undefined }),
99
+ );
100
+ assert.doesNotMatch(html, /title="Reply \(r\)"/);
101
+ });
102
+ });
@@ -0,0 +1,188 @@
1
+ import { ArrowLeft, Info } from "lucide-react";
2
+ import { type HTMLAttributes, type ReactNode, useState } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+ import type { ThreadData } from "./app-shell-types.js";
5
+ import { Button } from "./button.js";
6
+ import { Dialog } from "./dialog.js";
7
+ import {
8
+ type IntelligenceData,
9
+ IntelligencePanel,
10
+ } from "./intelligence-panel.js";
11
+ import { MobileMessageActionBar } from "./mobile-message-action-bar.js";
12
+ import { CollapsedMessage, ExpandedMessage } from "./reading-pane.js";
13
+
14
+ export interface MobileReadingMessageActions {
15
+ /** Expand / collapse a message row. */
16
+ onToggleExpand?: (id: string) => void;
17
+ onReply?: (id: string) => void;
18
+ onReplyAll?: (id: string) => void;
19
+ onForward?: (id: string) => void;
20
+ onToggleStar?: (id: string) => void;
21
+ onDelete?: (id: string) => void;
22
+ onToggleRead?: (id: string) => void;
23
+ /** Per-message move-to-folder trigger (the app supplies the folder picker). */
24
+ moveSlot?: (id: string) => ReactNode;
25
+ }
26
+
27
+ export interface MobileReadingPaneProps {
28
+ thread: ThreadData;
29
+ /** Back to the message list. */
30
+ onBack: () => void;
31
+ /**
32
+ * Intelligence for the active (expanded) message. Drives the single top-bar
33
+ * toggle; in a multi-message thread the caller passes the data for whichever
34
+ * message is currently expanded. Omit to hide the toggle.
35
+ */
36
+ intelligence?: IntelligenceData;
37
+ /** Controlled open state for the intelligence sheet. Omit for internal state. */
38
+ intelligenceOpen?: boolean;
39
+ onToggleIntelligence?: () => void;
40
+ actions?: MobileReadingMessageActions;
41
+ /**
42
+ * Live message content. When provided it replaces the static
43
+ * `thread.messages` rendering so the app can inject its own message cards
44
+ * (each expanded card owns its `MobileMessageActionBar`). `thread.subject`
45
+ * still drives the top bar.
46
+ */
47
+ children?: ReactNode;
48
+ /** Touch handlers attached to the scroll area (swipe-between-messages). */
49
+ touchHandlers?: HTMLAttributes<HTMLDivElement>;
50
+ }
51
+
52
+ /**
53
+ * The narrow-width (single-pane) mobile reading view, and the one shared shell
54
+ * every mobile reading mock composes. The chrome is fixed: a top app bar with
55
+ * back, the email subject and — its only other control — the intelligence
56
+ * toggle for the active message. Each expanded message carries its own
57
+ * `MobileMessageActionBar` (reply / star / move / delete / overflow); collapsed
58
+ * rows carry none. There is no thread-level reply footer — reply belongs to the
59
+ * message it answers. Feed it a `thread` and per-message `actions`; it owns the
60
+ * layout so no mock hand-rolls it.
61
+ */
62
+ export function MobileReadingPane({
63
+ thread,
64
+ onBack,
65
+ intelligence,
66
+ intelligenceOpen,
67
+ onToggleIntelligence,
68
+ actions,
69
+ children,
70
+ touchHandlers,
71
+ }: MobileReadingPaneProps) {
72
+ const [localIntelligenceOpen, setLocalIntelligenceOpen] = useState(false);
73
+ const controlled = onToggleIntelligence !== undefined;
74
+ const isOpen = controlled
75
+ ? (intelligenceOpen ?? false)
76
+ : localIntelligenceOpen;
77
+ const showIntelligenceToggle = Boolean(intelligence) || controlled;
78
+
79
+ const toggleIntelligence = () => {
80
+ if (controlled) {
81
+ onToggleIntelligence?.();
82
+ return;
83
+ }
84
+ setLocalIntelligenceOpen((value) => !value);
85
+ };
86
+
87
+ const closeIntelligence = () => {
88
+ if (controlled) {
89
+ if (isOpen) onToggleIntelligence?.();
90
+ return;
91
+ }
92
+ setLocalIntelligenceOpen(false);
93
+ };
94
+
95
+ return (
96
+ <article className="flex h-full w-full min-w-0 flex-col bg-canvas">
97
+ <header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line bg-surface px-row-inset">
98
+ <Button
99
+ variant="ghost"
100
+ size="sm"
101
+ icon={<ArrowLeft className="size-4" />}
102
+ onClick={onBack}
103
+ aria-label="Back to messages"
104
+ className="-ml-1 shrink-0"
105
+ />
106
+ <h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
107
+ {thread.subject}
108
+ </h2>
109
+ {showIntelligenceToggle && (
110
+ <Button
111
+ variant="ghost"
112
+ size="sm"
113
+ icon={<Info className="size-4" />}
114
+ onClick={toggleIntelligence}
115
+ aria-label={
116
+ isOpen ? "Hide intelligence panel" : "Show intelligence panel"
117
+ }
118
+ aria-pressed={isOpen}
119
+ title="Intelligence"
120
+ className={cn(
121
+ "shrink-0",
122
+ isOpen && "bg-accent-2-soft text-accent-2",
123
+ )}
124
+ />
125
+ )}
126
+ </header>
127
+
128
+ <div
129
+ className="flex-1 overflow-y-auto"
130
+ style={touchHandlers ? { touchAction: "pan-y" } : undefined}
131
+ {...touchHandlers}
132
+ >
133
+ {children ??
134
+ thread.messages.map((message) => {
135
+ const bind = (handler?: (id: string) => void) =>
136
+ handler ? () => handler(message.id) : undefined;
137
+
138
+ if (!message.expanded) {
139
+ return (
140
+ <CollapsedMessage
141
+ key={message.id}
142
+ message={message}
143
+ onClick={bind(actions?.onToggleExpand)}
144
+ />
145
+ );
146
+ }
147
+
148
+ return (
149
+ <ExpandedMessage
150
+ key={message.id}
151
+ message={message}
152
+ warning={thread.warning}
153
+ onHeaderClick={bind(actions?.onToggleExpand)}
154
+ actionBar={
155
+ <MobileMessageActionBar
156
+ hasThread
157
+ onReply={bind(actions?.onReply)}
158
+ onReplyAll={bind(actions?.onReplyAll)}
159
+ onForward={bind(actions?.onForward)}
160
+ onToggleStar={bind(actions?.onToggleStar)}
161
+ onDelete={bind(actions?.onDelete)}
162
+ onToggleRead={bind(actions?.onToggleRead)}
163
+ moveSlot={actions?.moveSlot?.(message.id)}
164
+ />
165
+ }
166
+ />
167
+ );
168
+ })}
169
+ </div>
170
+
171
+ {intelligence && !controlled && (
172
+ <Dialog
173
+ open={isOpen}
174
+ onClose={closeIntelligence}
175
+ title="Intelligence"
176
+ anchor="right"
177
+ className="p-0"
178
+ >
179
+ <IntelligencePanel
180
+ data={intelligence}
181
+ onClose={closeIntelligence}
182
+ className="h-full w-full border-l-0"
183
+ />
184
+ </Dialog>
185
+ )}
186
+ </article>
187
+ );
188
+ }
@@ -0,0 +1,247 @@
1
+ import type { Decorator, Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import {
4
+ briefFilterConfig,
5
+ type FilterAccount,
6
+ inboxFilterConfig,
7
+ } from "../filter-presets.js";
8
+ import { MobileSearchView } from "./mobile-search-view.js";
9
+ import type { SearchResult } from "./search-result-row.js";
10
+ import type { SearchResultSection } from "./search-results.js";
11
+
12
+ const phoneFrame: Decorator = (Story) => (
13
+ <div
14
+ className="overflow-hidden rounded-lg border border-line"
15
+ style={{ width: 390, height: 720 }}
16
+ >
17
+ <Story />
18
+ </div>
19
+ );
20
+
21
+ const accounts: FilterAccount[] = [
22
+ { id: "personal", label: "matthijs@", count: 42, active: true },
23
+ { id: "work", label: "work@acme", count: 17 },
24
+ ];
25
+
26
+ const recentSearches = ["invoice march", "from: stripe", "flight confirmation"];
27
+
28
+ const topMatches: SearchResult[] = [
29
+ {
30
+ id: "r1",
31
+ sender: "Stripe",
32
+ subject: "Your invoice for March is ready",
33
+ snippet: "Invoice #4821 — €149.00 paid on Visa ending 4242.",
34
+ date: "9:42",
35
+ unread: true,
36
+ category: { label: "Receipt", tone: "positive" },
37
+ },
38
+ {
39
+ id: "r2",
40
+ sender: "Hetzner Online",
41
+ subject: "Invoice 2026-03 available in your account",
42
+ snippet: "Dear customer, your invoice for the period is attached.",
43
+ date: "Mar 3",
44
+ category: { label: "Finance", tone: "accent" },
45
+ },
46
+ {
47
+ id: "r3",
48
+ sender: "Anna de Vries",
49
+ subject: "Re: Q1 invoice approval",
50
+ snippet: "Approved — can you forward the PDF invoice to finance?",
51
+ date: "Mar 1",
52
+ flagged: true,
53
+ },
54
+ {
55
+ id: "r4",
56
+ sender: "AWS Billing",
57
+ subject: "Your invoice is now available",
58
+ snippet: "Your total for February was $312.55 across 6 services.",
59
+ date: "Feb 28",
60
+ category: { label: "Finance", tone: "accent" },
61
+ },
62
+ ];
63
+
64
+ // Semantic "Related" hits carry their own thread + mailbox so they open directly
65
+ // — the matching message often lives outside the loaded list.
66
+ const related: SearchResult[] = [
67
+ {
68
+ id: "r5",
69
+ sender: "QuickBooks",
70
+ subject: "Reminder: 2 invoices awaiting payment",
71
+ snippet: "You have outstanding invoices totalling €430.00.",
72
+ date: "Feb 20",
73
+ category: { label: "Reminder", tone: "warning" },
74
+ threadId: "thread-quickbooks",
75
+ mailboxId: "mailbox-personal",
76
+ },
77
+ {
78
+ id: "r6",
79
+ sender: "noreply@vendor.io",
80
+ subject: "Overdue invoice notice",
81
+ snippet: "This invoice is now 14 days overdue. Please remit payment.",
82
+ date: "Feb 12",
83
+ category: { label: "Overdue", tone: "danger" },
84
+ threadId: "thread-vendor",
85
+ mailboxId: "mailbox-personal",
86
+ },
87
+ ];
88
+
89
+ const resultSections: SearchResultSection[] = [
90
+ { id: "top", label: "Top matches", results: topMatches },
91
+ { id: "related", label: "Related", results: related },
92
+ ];
93
+
94
+ const emptySections: SearchResultSection[] = [
95
+ { id: "top", label: "Top matches", results: [] },
96
+ { id: "related", label: "Related", results: [] },
97
+ ];
98
+
99
+ type Preset = "brief" | "inbox";
100
+
101
+ function Harness({
102
+ initialValue = "",
103
+ loading,
104
+ sections,
105
+ preset,
106
+ }: {
107
+ initialValue?: string;
108
+ loading?: boolean;
109
+ sections?: SearchResultSection[];
110
+ preset: Preset;
111
+ }) {
112
+ const [value, setValue] = useState(initialValue);
113
+ const [selectedCategory, setSelectedCategory] = useState("all");
114
+ const [activeFilters, setActiveFilters] = useState<Set<string>>(new Set());
115
+ const [activeSource, setActiveSource] = useState("personal");
116
+ const [expanded, setExpanded] = useState(false);
117
+ const [opened, setOpened] = useState<SearchResult | null>(null);
118
+
119
+ const base =
120
+ preset === "brief"
121
+ ? briefFilterConfig(
122
+ accounts.map((account) => ({
123
+ ...account,
124
+ active: account.id === activeSource,
125
+ })),
126
+ )
127
+ : inboxFilterConfig();
128
+
129
+ if (opened) {
130
+ return (
131
+ <div className="flex h-full flex-col items-center justify-center gap-2 bg-canvas p-6 text-center text-sm">
132
+ <p className="font-semibold text-fg">Opened conversation</p>
133
+ <p className="text-fg-muted">{opened.subject}</p>
134
+ <p className="text-2xs text-fg-subtle">
135
+ thread {opened.threadId ?? "(none)"} · mailbox{" "}
136
+ {opened.mailboxId ?? "(none)"}
137
+ </p>
138
+ <button
139
+ type="button"
140
+ className="mt-2 text-2xs text-accent underline"
141
+ onClick={() => setOpened(null)}
142
+ >
143
+ Back to search
144
+ </button>
145
+ </div>
146
+ );
147
+ }
148
+
149
+ return (
150
+ <MobileSearchView
151
+ value={value}
152
+ onChange={setValue}
153
+ onClear={() => setValue("")}
154
+ onCancel={() => undefined}
155
+ filter={{
156
+ ...base,
157
+ selectedCategory,
158
+ activeFilters,
159
+ expanded,
160
+ onExpandedChange: setExpanded,
161
+ onSelectCategory: setSelectedCategory,
162
+ onSelectSource: setActiveSource,
163
+ onToggleFilter: (id) =>
164
+ setActiveFilters((current) => {
165
+ const next = new Set(current);
166
+ if (next.has(id)) next.delete(id);
167
+ else next.add(id);
168
+ return next;
169
+ }),
170
+ onClear: () => {
171
+ setSelectedCategory("all");
172
+ setActiveFilters(new Set());
173
+ },
174
+ }}
175
+ recentSearches={recentSearches}
176
+ onPickRecent={setValue}
177
+ sections={sections}
178
+ loading={loading}
179
+ onSelectResult={setOpened}
180
+ />
181
+ );
182
+ }
183
+
184
+ const meta: Meta<typeof MobileSearchView> = {
185
+ title: "Kit/MobileSearchView",
186
+ component: MobileSearchView,
187
+ parameters: { layout: "centered" },
188
+ decorators: [phoneFrame],
189
+ };
190
+ export default meta;
191
+
192
+ type Story = StoryObj<typeof MobileSearchView>;
193
+
194
+ /**
195
+ * Global search — the daily-brief preset, so the FilterSheet carries the account
196
+ * source row (matthijs@ / work@acme) on top of the shared categories + filters.
197
+ * The header carries a single X that clears the query AND dismisses the takeover.
198
+ */
199
+ export const GlobalSearch: Story = {
200
+ render: () => (
201
+ <Harness initialValue="invoice" sections={resultSections} preset="brief" />
202
+ ),
203
+ };
204
+
205
+ /**
206
+ * Scoped search — a single inbox, so the inbox preset drops the account row
207
+ * entirely (the view is already scoped); same categories and filters otherwise.
208
+ */
209
+ export const ScopedSearch: Story = {
210
+ render: () => (
211
+ <Harness initialValue="invoice" sections={resultSections} preset="inbox" />
212
+ ),
213
+ };
214
+
215
+ /** Empty query: recent searches under the brief filter chrome. */
216
+ export const Idle: Story = {
217
+ render: () => <Harness preset="brief" />,
218
+ };
219
+
220
+ /** A query that matches nothing. */
221
+ export const NoResults: Story = {
222
+ render: () => (
223
+ <Harness initialValue="asdfqwer" sections={emptySections} preset="brief" />
224
+ ),
225
+ };
226
+
227
+ /** Results still loading. */
228
+ export const Loading: Story = {
229
+ render: () => <Harness initialValue="invoice" loading preset="brief" />,
230
+ };
231
+
232
+ /**
233
+ * Selecting a "Related" (semantic) hit. These rows carry their own thread +
234
+ * mailbox, so tapping one opens the conversation directly — even though the
235
+ * matching message lives outside the loaded list. Tap a row under "Related" to
236
+ * see the thread + mailbox the result hands the app to open. Regression cover for
237
+ * the brief bug where a tapped related result selected nothing.
238
+ */
239
+ export const RelatedSelectable: Story = {
240
+ render: () => (
241
+ <Harness
242
+ initialValue="invoice"
243
+ sections={[{ id: "related", label: "Related", results: related }]}
244
+ preset="brief"
245
+ />
246
+ ),
247
+ };