@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,342 @@
1
+ import { CheckCircle2, HelpCircle, ShieldCheck } from "lucide-react";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Banner } from "./banner.js";
4
+ import { BottomSheet } from "./bottom-sheet.js";
5
+ import { Button } from "./button.js";
6
+ import {
7
+ type MoveMailboxOption,
8
+ MoveMailboxPicker,
9
+ } from "./move-mailbox-picker.js";
10
+ import {
11
+ type RescueCandidate,
12
+ RescueCandidateRow,
13
+ } from "./rescue-candidate-row.js";
14
+
15
+ type Step = "review" | "destination" | "done";
16
+
17
+ export interface RescueFromSpamFlowProps {
18
+ open: boolean;
19
+ /** Suspected-safe messages, already shaped and explained by the app. */
20
+ candidates: RescueCandidate[];
21
+ /** Where the move lands unless the user picks another folder. */
22
+ defaultDestinationId: string;
23
+ /** Folders the user can send the rescued mail to. */
24
+ availableFolders: MoveMailboxOption[];
25
+ /** Run the move. The flow shows the done state right after. */
26
+ onConfirmMove: (messageIds: string[], destinationId: string) => void;
27
+ /** Close the sheet without moving anything. */
28
+ onCancel: () => void;
29
+ }
30
+
31
+ const plural = (n: number): string => (n === 1 ? "message" : "messages");
32
+
33
+ /**
34
+ * Plain-language scope line shown before the move commits, so the user sees
35
+ * exactly what happens and that it is a one-off — never an ongoing rule.
36
+ */
37
+ export const rescueMoveConsequence = (
38
+ count: number,
39
+ destinationLabel: string,
40
+ ): string =>
41
+ `Moves ${count === 1 ? "this message" : `these ${count} ${plural(count)}`} out of Spam to ${destinationLabel} now. Nothing later.`;
42
+
43
+ const folderLabel = (folders: MoveMailboxOption[], id: string): string =>
44
+ folders.find((f) => f.id === id)?.label ?? "Inbox";
45
+
46
+ function ExplainWhy() {
47
+ return (
48
+ <Banner tone="info" variant="soft">
49
+ <p className="text-2xs text-fg-muted">
50
+ We list a message here only when we can confirm the sender is who they
51
+ say — usually someone you've emailed before, write to often, or a
52
+ service that consistently checks out. We never guess from words in the
53
+ subject. Anything we can't confirm stays in Spam.
54
+ </p>
55
+ </Banner>
56
+ );
57
+ }
58
+
59
+ function ReviewStep({
60
+ candidates,
61
+ selected,
62
+ onToggle,
63
+ onSelectAll,
64
+ onSelectNone,
65
+ onContinue,
66
+ onCancel,
67
+ }: {
68
+ candidates: RescueCandidate[];
69
+ selected: Set<string>;
70
+ onToggle: (id: string) => void;
71
+ onSelectAll: () => void;
72
+ onSelectNone: () => void;
73
+ onContinue: () => void;
74
+ onCancel: () => void;
75
+ }) {
76
+ const [showWhy, setShowWhy] = useState(false);
77
+ const count = selected.size;
78
+ return (
79
+ <div className="flex min-h-0 flex-col">
80
+ <div className="px-row-inset pb-2 pt-1">
81
+ <div className="flex items-start justify-between gap-2">
82
+ <h2 className="flex items-center gap-2 text-sm font-semibold text-fg">
83
+ <ShieldCheck className="size-4 text-positive" aria-hidden />
84
+ Rescue from Spam
85
+ </h2>
86
+ <Button
87
+ variant="ghost"
88
+ size="sm"
89
+ icon={<HelpCircle className="size-3.5" />}
90
+ onClick={() => setShowWhy((v) => !v)}
91
+ className="-mr-1.5 -mt-0.5"
92
+ >
93
+ Why these?
94
+ </Button>
95
+ </div>
96
+ <p className="mt-1 text-2xs text-fg-subtle">
97
+ These look safe — from senders we can verify. Uncheck anything that
98
+ belongs in Spam.
99
+ </p>
100
+ {showWhy && (
101
+ <div className="mt-2">
102
+ <ExplainWhy />
103
+ </div>
104
+ )}
105
+ </div>
106
+
107
+ <div className="flex items-center justify-between px-row-inset pb-1.5">
108
+ <span className="text-2xs font-medium text-fg-muted">
109
+ {`${count} of ${candidates.length} selected`}
110
+ </span>
111
+ <span className="flex gap-1">
112
+ <Button variant="ghost" size="sm" onClick={onSelectAll}>
113
+ Select all
114
+ </Button>
115
+ <Button variant="ghost" size="sm" onClick={onSelectNone}>
116
+ Select none
117
+ </Button>
118
+ </span>
119
+ </div>
120
+
121
+ <div className="min-h-0 flex-1 divide-y divide-line overflow-y-auto border-y border-line">
122
+ {candidates.map((candidate) => (
123
+ <RescueCandidateRow
124
+ key={candidate.id}
125
+ candidate={candidate}
126
+ selected={selected.has(candidate.id)}
127
+ onToggle={() => onToggle(candidate.id)}
128
+ />
129
+ ))}
130
+ </div>
131
+
132
+ <div className="space-y-2 border-t border-line bg-surface px-row-inset py-3">
133
+ <Button
134
+ variant="primary"
135
+ onClick={onContinue}
136
+ className="h-11 w-full font-semibold"
137
+ >
138
+ {`Continue with ${count} ${plural(count)}`}
139
+ </Button>
140
+ <Button variant="ghost" onClick={onCancel} className="h-11 w-full">
141
+ Cancel
142
+ </Button>
143
+ </div>
144
+ </div>
145
+ );
146
+ }
147
+
148
+ function DestinationStep({
149
+ count,
150
+ folders,
151
+ destination,
152
+ onPick,
153
+ onMove,
154
+ onBack,
155
+ }: {
156
+ count: number;
157
+ folders: MoveMailboxOption[];
158
+ destination: string;
159
+ onPick: (id: string) => void;
160
+ onMove: () => void;
161
+ onBack: () => void;
162
+ }) {
163
+ const [picking, setPicking] = useState(false);
164
+
165
+ return (
166
+ <div className="flex min-h-0 flex-col">
167
+ <div className="px-row-inset pb-2 pt-1">
168
+ <h2 className="text-sm font-semibold text-fg">Where should they go?</h2>
169
+ <p className="mt-1 text-2xs text-fg-subtle">
170
+ Pick a folder, then we move them out of Spam.
171
+ </p>
172
+ </div>
173
+
174
+ <div className="min-h-0 flex-1 overflow-y-auto px-row-inset py-2">
175
+ {picking ? (
176
+ <div className="overflow-hidden rounded-xl border border-line bg-surface">
177
+ <MoveMailboxPicker
178
+ mailboxes={folders}
179
+ autoFocus
180
+ onSelect={(id) => {
181
+ onPick(id);
182
+ setPicking(false);
183
+ }}
184
+ onCancel={() => setPicking(false)}
185
+ />
186
+ </div>
187
+ ) : (
188
+ <div className="rounded-xl border border-line bg-surface-sunken p-3">
189
+ <p className="text-2xs text-fg-subtle">Destination</p>
190
+ <p className="mt-0.5 text-sm font-medium text-fg">
191
+ {folderLabel(folders, destination)}
192
+ </p>
193
+ <Button
194
+ variant="ghost"
195
+ size="sm"
196
+ onClick={() => setPicking(true)}
197
+ className="mt-2 px-0"
198
+ >
199
+ Move to a different folder
200
+ </Button>
201
+ </div>
202
+ )}
203
+ </div>
204
+
205
+ <div className="space-y-2 border-t border-line bg-surface px-row-inset py-3">
206
+ <p className="text-2xs text-fg-subtle">
207
+ {rescueMoveConsequence(count, folderLabel(folders, destination))}
208
+ </p>
209
+ <Button
210
+ variant="primary"
211
+ onClick={onMove}
212
+ className="h-11 w-full font-semibold"
213
+ >
214
+ {`Move ${count} out of Spam`}
215
+ </Button>
216
+ <Button variant="ghost" onClick={onBack} className="h-11 w-full">
217
+ Back
218
+ </Button>
219
+ </div>
220
+ </div>
221
+ );
222
+ }
223
+
224
+ function DoneStep({
225
+ count,
226
+ destinationLabel,
227
+ onClose,
228
+ }: {
229
+ count: number;
230
+ destinationLabel: string;
231
+ onClose: () => void;
232
+ }) {
233
+ return (
234
+ <div className="flex min-h-0 flex-col">
235
+ <div className="flex flex-1 flex-col items-center justify-center gap-3 px-row-inset py-10 text-center">
236
+ <CheckCircle2 className="size-10 text-positive" aria-hidden />
237
+ <p className="text-base font-semibold text-fg">
238
+ {`Moved ${count} ${plural(count)} out of Spam.`}
239
+ </p>
240
+ <p className="text-2xs text-fg-subtle">
241
+ {`You'll find them in ${destinationLabel}.`}
242
+ </p>
243
+ </div>
244
+ <div className="border-t border-line bg-surface px-row-inset py-3">
245
+ <Button
246
+ variant="primary"
247
+ onClick={onClose}
248
+ className="h-11 w-full font-semibold"
249
+ >
250
+ Done
251
+ </Button>
252
+ </div>
253
+ </div>
254
+ );
255
+ }
256
+
257
+ /**
258
+ * Review-and-rescue flow for messages that landed in Spam but come from senders
259
+ * we can verify. Presentational and fully driven: the app supplies candidates,
260
+ * folders and the move callback; the sheet owns only selection and step state.
261
+ *
262
+ * Pre-selects every candidate, supports select-all / none, explains "why these"
263
+ * on demand, states the move's scope as a one-off consequence, and always
264
+ * offers a way back before committing.
265
+ */
266
+ export function RescueFromSpamFlow({
267
+ open,
268
+ candidates,
269
+ defaultDestinationId,
270
+ availableFolders,
271
+ onConfirmMove,
272
+ onCancel,
273
+ }: RescueFromSpamFlowProps) {
274
+ const [step, setStep] = useState<Step>("review");
275
+ const [selected, setSelected] = useState<Set<string>>(() =>
276
+ open ? new Set(candidates.map((c) => c.id)) : new Set(),
277
+ );
278
+ const [destination, setDestination] = useState(defaultDestinationId);
279
+ const [movedCount, setMovedCount] = useState(0);
280
+
281
+ const wasOpen = useRef(false);
282
+ useEffect(() => {
283
+ if (open && !wasOpen.current) {
284
+ setStep("review");
285
+ setSelected(new Set(candidates.map((c) => c.id)));
286
+ setDestination(defaultDestinationId);
287
+ setMovedCount(0);
288
+ }
289
+ wasOpen.current = open;
290
+ }, [open, candidates, defaultDestinationId]);
291
+
292
+ const selectedCount = selected.size;
293
+
294
+ const toggle = (id: string) => {
295
+ setSelected((prev) => {
296
+ const next = new Set(prev);
297
+ if (next.has(id)) next.delete(id);
298
+ else next.add(id);
299
+ return next;
300
+ });
301
+ };
302
+
303
+ const move = () => {
304
+ const ids = Array.from(selected);
305
+ setMovedCount(ids.length);
306
+ onConfirmMove(ids, destination);
307
+ setStep("done");
308
+ };
309
+
310
+ return (
311
+ <BottomSheet open={open} onClose={onCancel} dismissLabel="Close rescue">
312
+ {step === "review" && (
313
+ <ReviewStep
314
+ candidates={candidates}
315
+ selected={selected}
316
+ onToggle={toggle}
317
+ onSelectAll={() => setSelected(new Set(candidates.map((c) => c.id)))}
318
+ onSelectNone={() => setSelected(new Set())}
319
+ onContinue={() => setStep("destination")}
320
+ onCancel={onCancel}
321
+ />
322
+ )}
323
+ {step === "destination" && (
324
+ <DestinationStep
325
+ count={selectedCount}
326
+ folders={availableFolders}
327
+ destination={destination}
328
+ onPick={setDestination}
329
+ onMove={move}
330
+ onBack={() => setStep("review")}
331
+ />
332
+ )}
333
+ {step === "done" && (
334
+ <DoneStep
335
+ count={movedCount}
336
+ destinationLabel={folderLabel(availableFolders, destination)}
337
+ onClose={onCancel}
338
+ />
339
+ )}
340
+ </BottomSheet>
341
+ );
342
+ }
@@ -0,0 +1,55 @@
1
+ import {
2
+ Panel,
3
+ PanelGroup,
4
+ type PanelGroupProps,
5
+ type PanelProps,
6
+ PanelResizeHandle,
7
+ type PanelResizeHandleProps,
8
+ } from "react-resizable-panels";
9
+ import { cn } from "../lib/cn.js";
10
+
11
+ /* ------------------------------------------------------------------ */
12
+ /* Resizable panes — same library + wrapper pattern as the production */
13
+ /* web client (react-resizable-panels), restyled on the design */
14
+ /* tokens. The drag handle IS the hairline: a 1px --line divider with */
15
+ /* an invisible 6px hit area centered on it; it lifts to */
16
+ /* --line-strong on hover and --accent while dragging. */
17
+ /* ------------------------------------------------------------------ */
18
+
19
+ export function ResizablePanelGroup({ className, ...props }: PanelGroupProps) {
20
+ return (
21
+ <PanelGroup
22
+ className={cn(
23
+ "flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
24
+ className,
25
+ )}
26
+ {...props}
27
+ />
28
+ );
29
+ }
30
+
31
+ export const ResizablePanel = Panel;
32
+
33
+ export function ResizableHandle({
34
+ className,
35
+ ...props
36
+ }: PanelResizeHandleProps) {
37
+ return (
38
+ <PanelResizeHandle
39
+ className={cn(
40
+ // the visible 1px hairline
41
+ "relative w-px shrink-0 bg-line transition-colors cursor-col-resize",
42
+ // invisible ~6px hit area centered on the line
43
+ "after:absolute after:inset-y-0 after:left-1/2 after:w-1.5 after:-translate-x-1/2",
44
+ // affordance: hairline lifts on hover, accent while dragging
45
+ "data-[resize-handle-state=hover]:bg-line-strong",
46
+ "data-[resize-handle-state=drag]:bg-accent",
47
+ "focus-visible:outline-none focus-visible:bg-accent",
48
+ className,
49
+ )}
50
+ {...props}
51
+ />
52
+ );
53
+ }
54
+
55
+ export type { PanelGroupProps, PanelProps, PanelResizeHandleProps };
@@ -0,0 +1,97 @@
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 {
6
+ type CandidateFolder,
7
+ RoleAppointmentList,
8
+ } from "./role-appointment-list.js";
9
+
10
+ const noop = () => {};
11
+
12
+ const folders: CandidateFolder[] = [
13
+ { mailboxId: "mb-inbox", providerPath: "INBOX", messageCount: 4821 },
14
+ {
15
+ mailboxId: "mb-concepten",
16
+ providerPath: "INBOX/Concepten",
17
+ messageCount: 340,
18
+ },
19
+ { mailboxId: "mb-drafts", providerPath: "INBOX/Drafts", messageCount: 0 },
20
+ {
21
+ mailboxId: "mb-news",
22
+ providerPath: "INBOX/Nieuwsbrieven",
23
+ messageCount: 2870,
24
+ },
25
+ ];
26
+
27
+ function render(
28
+ appointments: Record<string, string | null>,
29
+ displayNames: Record<string, string> = {},
30
+ ): string {
31
+ return renderToString(
32
+ createElement(RoleAppointmentList, {
33
+ accountEmail: "you@example.com",
34
+ folders,
35
+ appointments,
36
+ displayNames,
37
+ onAppoint: noop,
38
+ onRename: noop,
39
+ }),
40
+ );
41
+ }
42
+
43
+ describe("RoleAppointmentList", () => {
44
+ it("titles the section with the account email", () => {
45
+ const html = render({ inbox: "mb-inbox" });
46
+ assert.match(html, /Folder roles —/);
47
+ assert.match(html, /you@example.com/);
48
+ });
49
+
50
+ it("renders a row for every appointable role", () => {
51
+ const html = render({});
52
+ for (const label of [
53
+ "Inbox",
54
+ "Drafts",
55
+ "Sent",
56
+ "Archive",
57
+ "Spam",
58
+ "Trash",
59
+ ]) {
60
+ assert.match(html, new RegExp(`Folder for ${label}`));
61
+ }
62
+ });
63
+
64
+ it("offers every folder with its message count as a picker option", () => {
65
+ const html = render({});
66
+ assert.match(html, /Concepten · 340 msgs/);
67
+ assert.match(html, /Drafts · 0 msgs/);
68
+ assert.match(html, /None/);
69
+ });
70
+
71
+ it("shows the appointed folder's path and count under the role", () => {
72
+ const html = render({ drafts: "mb-concepten" });
73
+ assert.match(html, /title="INBOX\/Concepten"/);
74
+ assert.match(html, /340/);
75
+ assert.match(html, /messages/);
76
+ });
77
+
78
+ it("renders a rename field only for an appointed role", () => {
79
+ const html = render({ drafts: "mb-concepten" });
80
+ assert.match(html, /Display name for Drafts/);
81
+ assert.doesNotMatch(html, /Display name for Sent/);
82
+ });
83
+
84
+ it("lists unappointed folders under Other folders", () => {
85
+ const html = render({ drafts: "mb-concepten", inbox: "mb-inbox" });
86
+ assert.match(html, /Other folders/);
87
+ assert.match(html, /Nieuwsbrieven/);
88
+ assert.match(html, /Drafts/);
89
+ });
90
+
91
+ it("keeps an appointed folder out of the Other folders list", () => {
92
+ const html = render({ drafts: "mb-concepten" });
93
+ const otherIdx = html.indexOf("Other folders");
94
+ assert.ok(otherIdx >= 0);
95
+ assert.doesNotMatch(html.slice(otherIdx), /Concepten/);
96
+ });
97
+ });
@@ -0,0 +1,138 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import type { FolderRole } from "./folder-role.js";
4
+ import {
5
+ type CandidateFolder,
6
+ RoleAppointmentList,
7
+ } from "./role-appointment-list.js";
8
+
9
+ /**
10
+ * The user's real Hostnet account (440737+mvhenten@users.noreply.github.com). Every folder is nested
11
+ * directly under the personal-namespace root `INBOX/`. The pair that breaks the
12
+ * old per-folder model is here: the server flags `INBOX/Drafts` with `\Drafts`
13
+ * but it is EMPTY — the mail lives in `INBOX/Concepten`. Same story for
14
+ * `INBOX/Sent` vs `INBOX/Sent Messages`.
15
+ *
16
+ * NOTE: message counts are representative placeholders until live dev-stage
17
+ * numbers are wired in.
18
+ */
19
+ const HOSTNET_FOLDERS: readonly CandidateFolder[] = [
20
+ { mailboxId: "mb-inbox", providerPath: "INBOX", messageCount: 4821 },
21
+ {
22
+ mailboxId: "mb-archive",
23
+ providerPath: "INBOX/Archive",
24
+ messageCount: 19243,
25
+ },
26
+ {
27
+ mailboxId: "mb-concepten",
28
+ providerPath: "INBOX/Concepten",
29
+ messageCount: 340,
30
+ },
31
+ {
32
+ mailboxId: "mb-deleted",
33
+ providerPath: "INBOX/Deleted Messages",
34
+ messageCount: 512,
35
+ },
36
+ { mailboxId: "mb-drafts", providerPath: "INBOX/Drafts", messageCount: 0 },
37
+ {
38
+ mailboxId: "mb-news",
39
+ providerPath: "INBOX/Nieuwsbrieven",
40
+ messageCount: 2870,
41
+ },
42
+ { mailboxId: "mb-sent", providerPath: "INBOX/Sent", messageCount: 0 },
43
+ {
44
+ mailboxId: "mb-sent-messages",
45
+ providerPath: "INBOX/Sent Messages",
46
+ messageCount: 6105,
47
+ },
48
+ { mailboxId: "mb-spam", providerPath: "INBOX/Spam", messageCount: 88 },
49
+ ];
50
+
51
+ function Harness({
52
+ folders,
53
+ initial,
54
+ }: {
55
+ folders: readonly CandidateFolder[];
56
+ initial: Record<string, string | null>;
57
+ }) {
58
+ const [appointments, setAppointments] = useState(initial);
59
+ const [displayNames, setDisplayNames] = useState<Record<string, string>>({});
60
+
61
+ const handleAppoint = (role: FolderRole, mailboxId: string | null) => {
62
+ setAppointments((prev) => {
63
+ const next: Record<string, string | null> = {
64
+ ...prev,
65
+ [role]: mailboxId,
66
+ };
67
+ // Exclusivity: appointing a folder to one role clears it from any other.
68
+ if (mailboxId) {
69
+ for (const r of Object.keys(next)) {
70
+ if (r !== role && next[r] === mailboxId) next[r] = null;
71
+ }
72
+ }
73
+ return next;
74
+ });
75
+ };
76
+
77
+ const handleRename = (mailboxId: string, name: string) =>
78
+ setDisplayNames((prev) => ({ ...prev, [mailboxId]: name.trim() }));
79
+
80
+ return (
81
+ <div className="max-w-3xl p-8">
82
+ <RoleAppointmentList
83
+ accountEmail="440737+mvhenten@users.noreply.github.com"
84
+ folders={folders}
85
+ appointments={appointments}
86
+ displayNames={displayNames}
87
+ onAppoint={handleAppoint}
88
+ onRename={handleRename}
89
+ />
90
+ </div>
91
+ );
92
+ }
93
+
94
+ const meta: Meta<typeof Harness> = {
95
+ title: "Settings/RoleAppointmentList",
96
+ component: Harness,
97
+ };
98
+ export default meta;
99
+
100
+ type Story = StoryObj<typeof Harness>;
101
+
102
+ /**
103
+ * The intended end state: each role points at the folder that holds the mail —
104
+ * Drafts → Concepten · 340, not the empty `INBOX/Drafts`; Sent → Sent Messages.
105
+ * The empty look-alikes drop to "Other folders".
106
+ */
107
+ export const Hostnet: Story = {
108
+ args: {
109
+ folders: HOSTNET_FOLDERS,
110
+ initial: {
111
+ inbox: "mb-inbox",
112
+ drafts: "mb-concepten",
113
+ sent: "mb-sent-messages",
114
+ archive: "mb-archive",
115
+ junk: "mb-spam",
116
+ trash: "mb-deleted",
117
+ },
118
+ },
119
+ };
120
+
121
+ /**
122
+ * Flag-first proposal before the user corrects it: detection appointed the
123
+ * `\Drafts`-flagged but empty `INBOX/Drafts` (0) and the empty `INBOX/Sent`.
124
+ * The picker counts reveal the real folders so the user can re-appoint.
125
+ */
126
+ export const ProposedDefaults: Story = {
127
+ args: {
128
+ folders: HOSTNET_FOLDERS,
129
+ initial: {
130
+ inbox: "mb-inbox",
131
+ drafts: "mb-drafts",
132
+ sent: "mb-sent",
133
+ archive: "mb-archive",
134
+ junk: "mb-spam",
135
+ trash: "mb-deleted",
136
+ },
137
+ },
138
+ };