@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,321 @@
1
+ import { Check } from "lucide-react";
2
+ import { type ReactNode, useId, useState } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+ import { Badge, type BadgeProps } from "./badge.js";
5
+ import { Card } from "./card.js";
6
+ import { FieldLabel } from "./field-label.js";
7
+ import { Input } from "./input.js";
8
+ import { SecuritySelect, type ServerSecurity } from "./security-select.js";
9
+
10
+ /* ------------------------------------------------------------------ */
11
+ /* WizardShell: centered card + step rail used by onboarding and the */
12
+ /* settings add-account flow. Any flow with >1-2 decisions is a */
13
+ /* wizard — this is the one implementation. */
14
+ /* ------------------------------------------------------------------ */
15
+
16
+ export interface WizardShellProps {
17
+ /** Ordered step labels for the rail. */
18
+ steps: string[];
19
+ /** Zero-based index of the active step. */
20
+ activeStep: number;
21
+ title: string;
22
+ subtitle?: string;
23
+ children: ReactNode;
24
+ /** Footer slot: back/continue buttons, hints. */
25
+ footer?: ReactNode;
26
+ /** Hide the rail (welcome screen). */
27
+ hideSteps?: boolean;
28
+ }
29
+
30
+ function StepRail({
31
+ steps,
32
+ activeStep,
33
+ }: Pick<WizardShellProps, "steps" | "activeStep">) {
34
+ return (
35
+ <ol className="flex items-center gap-1">
36
+ {steps.map((label, i) => {
37
+ const done = i < activeStep;
38
+ const active = i === activeStep;
39
+ return (
40
+ <li key={label} className="flex items-center gap-1">
41
+ {i > 0 && <span className="h-px w-5 bg-line" aria-hidden />}
42
+ <span className="flex items-center gap-1.5">
43
+ <span
44
+ className={cn(
45
+ "flex size-5 items-center justify-center rounded-full text-2xs font-semibold tabular-nums",
46
+ done && "bg-accent text-accent-fg",
47
+ active && "bg-accent-soft text-accent ring-1 ring-accent",
48
+ !done && !active && "bg-surface-sunken text-fg-subtle",
49
+ )}
50
+ >
51
+ {done ? <Check className="size-3" /> : i + 1}
52
+ </span>
53
+ <span
54
+ className={cn(
55
+ "hidden text-2xs sm:inline",
56
+ active ? "font-medium text-fg" : "text-fg-subtle",
57
+ )}
58
+ >
59
+ {label}
60
+ </span>
61
+ </span>
62
+ </li>
63
+ );
64
+ })}
65
+ </ol>
66
+ );
67
+ }
68
+
69
+ export function WizardShell({
70
+ steps,
71
+ activeStep,
72
+ title,
73
+ subtitle,
74
+ children,
75
+ footer,
76
+ hideSteps,
77
+ }: WizardShellProps) {
78
+ return (
79
+ // First-run is a focal moment: the wizard column is horizontally
80
+ // centered with its top edge anchored ~30% down the viewport
81
+ // (optical center) on larger screens. On phone the top padding is
82
+ // minimal so taller steps (connector picker, servers) don't push the
83
+ // CTA bar below the fold — the shell scrolls if content overflows.
84
+ <div className="flex min-h-dvh w-full flex-col items-center overflow-y-auto bg-canvas px-8 pb-8 pt-8 font-sans text-fg sm:pt-[30vh]">
85
+ {!hideSteps && (
86
+ <div className="mb-5 w-full max-w-xl">
87
+ <StepRail steps={steps} activeStep={activeStep} />
88
+ </div>
89
+ )}
90
+ <Card raised className="w-full max-w-xl">
91
+ <div className="px-5 pt-5">
92
+ <h1 className="text-xl font-semibold text-fg">{title}</h1>
93
+ {subtitle && <p className="mt-1 text-sm text-fg-muted">{subtitle}</p>}
94
+ </div>
95
+ <div className="px-5 py-5">{children}</div>
96
+ {footer && (
97
+ <div className="flex items-center justify-between gap-3 border-t border-line px-5 py-3">
98
+ {footer}
99
+ </div>
100
+ )}
101
+ </Card>
102
+ </div>
103
+ );
104
+ }
105
+
106
+ /* ------------------------------------------------------------------ */
107
+ /* ConnectorTile: connector picker option (IMAP now, OAuth later). */
108
+ /* ------------------------------------------------------------------ */
109
+
110
+ export interface ConnectorTileProps {
111
+ name: string;
112
+ description: string;
113
+ icon: ReactNode;
114
+ /** "coming soon" connectors render muted with a badge — still pressable. */
115
+ comingSoon?: boolean;
116
+ selected?: boolean;
117
+ onSelect?: () => void;
118
+ }
119
+
120
+ // Per the never-disable tenet a "soon" tile stays pressable: with no handler it
121
+ // surfaces a one-line explainer instead of going dead.
122
+ const COMING_SOON_HINT = "Coming soon — use IMAP for now.";
123
+
124
+ export function ConnectorTile({
125
+ name,
126
+ description,
127
+ icon,
128
+ comingSoon,
129
+ selected,
130
+ onSelect,
131
+ }: ConnectorTileProps) {
132
+ const [showHint, setShowHint] = useState(false);
133
+
134
+ const handleClick = () => {
135
+ if (onSelect) {
136
+ onSelect();
137
+ return;
138
+ }
139
+ if (comingSoon) setShowHint(true);
140
+ };
141
+
142
+ return (
143
+ <div className="flex flex-col gap-1.5">
144
+ <button
145
+ type="button"
146
+ onClick={handleClick}
147
+ aria-disabled={comingSoon || undefined}
148
+ className={cn(
149
+ "flex flex-col items-start gap-2 rounded-sm border p-3 text-left transition-colors",
150
+ selected
151
+ ? "border-accent-2 bg-accent-2-soft"
152
+ : "border-line bg-surface hover:border-line-strong",
153
+ comingSoon && "opacity-55",
154
+ )}
155
+ >
156
+ <span className={cn("text-fg-muted", selected && "text-accent-2")}>
157
+ {icon}
158
+ </span>
159
+ <span className="flex items-center gap-2">
160
+ <span className="text-sm font-semibold text-fg">{name}</span>
161
+ {comingSoon && (
162
+ <span className="rounded-full bg-surface-sunken px-1.5 py-0.5 text-2xs text-fg-subtle">
163
+ soon
164
+ </span>
165
+ )}
166
+ </span>
167
+ <span className="text-xs text-fg-subtle">{description}</span>
168
+ </button>
169
+ {showHint && (
170
+ <p className="text-2xs text-fg-subtle">{COMING_SOON_HINT}</p>
171
+ )}
172
+ </div>
173
+ );
174
+ }
175
+
176
+ /* ------------------------------------------------------------------ */
177
+ /* Server settings field group: host / port / security for one */
178
+ /* protocol (IMAP or SMTP). Responsive — stacks to one column on phone */
179
+ /* so the Security select is never clipped — and shared by the */
180
+ /* onboarding flow and the settings add-account form. */
181
+ /* ------------------------------------------------------------------ */
182
+
183
+ export interface ServerFieldsProps {
184
+ /** "IMAP — incoming" / "SMTP — outgoing". */
185
+ legend: string;
186
+ /** Optional badge after the legend ("detected" / "preset"). */
187
+ badge?: { label: string; tone: BadgeProps["tone"] };
188
+ host: string;
189
+ port: string;
190
+ security: ServerSecurity;
191
+ /** Locked presets render read-only and omit change handlers. */
192
+ readOnly?: boolean;
193
+ onHostChange?: (value: string) => void;
194
+ onPortChange?: (value: string) => void;
195
+ onSecurityChange?: (value: ServerSecurity) => void;
196
+ hostPlaceholder?: string;
197
+ portPlaceholder?: string;
198
+ }
199
+
200
+ export function ServerFields({
201
+ legend,
202
+ badge,
203
+ host,
204
+ port,
205
+ security,
206
+ readOnly,
207
+ onHostChange,
208
+ onPortChange,
209
+ onSecurityChange,
210
+ hostPlaceholder,
211
+ portPlaceholder,
212
+ }: ServerFieldsProps) {
213
+ const hostId = useId();
214
+ const portId = useId();
215
+ const securityId = useId();
216
+ return (
217
+ <fieldset>
218
+ <legend className="flex items-center gap-2 text-sm font-semibold text-fg">
219
+ {legend}
220
+ {badge && <Badge tone={badge.tone}>{badge.label}</Badge>}
221
+ </legend>
222
+ <div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-[1fr_6rem_8rem] sm:gap-2">
223
+ <div>
224
+ <FieldLabel htmlFor={hostId}>Host</FieldLabel>
225
+ {onHostChange ? (
226
+ <Input
227
+ id={hostId}
228
+ readOnly={readOnly}
229
+ value={host}
230
+ onChange={(e) => onHostChange(e.target.value)}
231
+ placeholder={hostPlaceholder}
232
+ />
233
+ ) : (
234
+ <Input
235
+ id={hostId}
236
+ readOnly={readOnly}
237
+ defaultValue={host}
238
+ placeholder={hostPlaceholder}
239
+ />
240
+ )}
241
+ </div>
242
+ <div>
243
+ <FieldLabel htmlFor={portId}>Port</FieldLabel>
244
+ {onPortChange ? (
245
+ <Input
246
+ id={portId}
247
+ readOnly={readOnly}
248
+ value={port}
249
+ onChange={(e) => onPortChange(e.target.value)}
250
+ placeholder={portPlaceholder}
251
+ />
252
+ ) : (
253
+ <Input
254
+ id={portId}
255
+ readOnly={readOnly}
256
+ defaultValue={port}
257
+ placeholder={portPlaceholder}
258
+ />
259
+ )}
260
+ </div>
261
+ <div>
262
+ <FieldLabel htmlFor={securityId}>Security</FieldLabel>
263
+ {onSecurityChange ? (
264
+ <SecuritySelect
265
+ id={securityId}
266
+ value={security}
267
+ onValueChange={onSecurityChange}
268
+ />
269
+ ) : (
270
+ <SecuritySelect id={securityId} defaultValue={security} />
271
+ )}
272
+ </div>
273
+ </div>
274
+ </fieldset>
275
+ );
276
+ }
277
+
278
+ /* ------------------------------------------------------------------ */
279
+ /* CheckRow: live status line for connection tests & sync checks. */
280
+ /* ------------------------------------------------------------------ */
281
+
282
+ export interface CheckRowProps {
283
+ label: string;
284
+ detail?: string;
285
+ state: "pending" | "running" | "ok" | "failed";
286
+ }
287
+
288
+ export function CheckRow({ label, detail, state }: CheckRowProps) {
289
+ return (
290
+ <div className="flex items-start gap-3 py-2">
291
+ <span
292
+ className={cn(
293
+ "mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-2xs",
294
+ state === "ok" && "bg-positive text-accent-fg",
295
+ state === "failed" && "bg-danger text-accent-fg",
296
+ state === "running" && "border border-accent text-accent",
297
+ state === "pending" && "border border-line text-fg-subtle",
298
+ )}
299
+ >
300
+ {state === "ok" && <Check className="size-3" />}
301
+ {state === "failed" && "!"}
302
+ {state === "running" && (
303
+ <span className="size-2 animate-pulse rounded-full bg-accent" />
304
+ )}
305
+ </span>
306
+ <div className="min-w-0">
307
+ <div
308
+ className={cn(
309
+ "text-sm",
310
+ state === "failed" ? "font-medium text-danger" : "text-fg",
311
+ )}
312
+ >
313
+ {label}
314
+ </div>
315
+ {detail && (
316
+ <div className="mt-0.5 text-xs text-fg-subtle">{detail}</div>
317
+ )}
318
+ </div>
319
+ </div>
320
+ );
321
+ }
@@ -0,0 +1,93 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ briefFilterConfig,
5
+ type FilterAccount,
6
+ flaggedFilterConfig,
7
+ inboxFilterConfig,
8
+ } from "./filter-presets.js";
9
+
10
+ const accounts: FilterAccount[] = [
11
+ { id: "all", label: "All", active: true },
12
+ { id: "personal", label: "Personal", count: 9 },
13
+ { id: "work", label: "Work", count: 14 },
14
+ ];
15
+
16
+ describe("briefFilterConfig", () => {
17
+ it("offers the message categories with a leading All", () => {
18
+ const { categories } = briefFilterConfig();
19
+ assert.equal(categories[0]?.id, "all");
20
+ assert.deepEqual(
21
+ categories.map((c) => c.id),
22
+ [
23
+ "all",
24
+ "personal",
25
+ "transactional",
26
+ "newsletter",
27
+ "marketing",
28
+ "social",
29
+ "automated",
30
+ ],
31
+ );
32
+ });
33
+
34
+ it("offers the BriefSections chip set", () => {
35
+ assert.deepEqual(
36
+ briefFilterConfig().filters.map((f) => f.id),
37
+ ["unread", "attachment", "contacts", "today"],
38
+ );
39
+ });
40
+
41
+ it("includes the accounts source group when more than one account", () => {
42
+ const { sources } = briefFilterConfig(accounts);
43
+ assert.deepEqual(
44
+ sources?.map((s) => s.id),
45
+ ["all", "personal", "work"],
46
+ );
47
+ });
48
+
49
+ it("omits the accounts source group for a single account", () => {
50
+ assert.equal(briefFilterConfig(accounts.slice(0, 1)).sources, undefined);
51
+ assert.equal(briefFilterConfig().sources, undefined);
52
+ });
53
+ });
54
+
55
+ describe("inboxFilterConfig", () => {
56
+ it("offers the same message categories as the brief", () => {
57
+ assert.deepEqual(
58
+ inboxFilterConfig().categories.map((c) => c.id),
59
+ briefFilterConfig().categories.map((c) => c.id),
60
+ );
61
+ });
62
+
63
+ it("adds Has attachment to Unread and Flagged", () => {
64
+ assert.deepEqual(
65
+ inboxFilterConfig().filters.map((f) => f.id),
66
+ ["unread", "flagged", "attachment"],
67
+ );
68
+ });
69
+
70
+ it("never offers an accounts source group", () => {
71
+ assert.equal(inboxFilterConfig().sources, undefined);
72
+ });
73
+ });
74
+
75
+ describe("flaggedFilterConfig", () => {
76
+ it("offers the same message categories as the brief", () => {
77
+ assert.deepEqual(
78
+ flaggedFilterConfig().categories.map((c) => c.id),
79
+ briefFilterConfig().categories.map((c) => c.id),
80
+ );
81
+ });
82
+
83
+ it("offers Unread and Has attachment, never the redundant Flagged", () => {
84
+ assert.deepEqual(
85
+ flaggedFilterConfig().filters.map((f) => f.id),
86
+ ["unread", "attachment"],
87
+ );
88
+ });
89
+
90
+ it("never offers an accounts source group", () => {
91
+ assert.equal(flaggedFilterConfig().sources, undefined);
92
+ });
93
+ });
@@ -0,0 +1,101 @@
1
+ import { briefFilterChips } from "./components/brief-sections.js";
2
+ import type {
3
+ FilterSheetCategory,
4
+ FilterSheetFilter,
5
+ FilterSheetSource,
6
+ } from "./components/filter-sheet.js";
7
+
8
+ /**
9
+ * An account as a filter dimension. Accounts segment the unified daily brief
10
+ * (the aggregate of every account); an inbox is already scoped to one account,
11
+ * so the inbox preset never offers them.
12
+ */
13
+ export interface FilterAccount {
14
+ id: string;
15
+ label: string;
16
+ count?: number;
17
+ active?: boolean;
18
+ }
19
+
20
+ /**
21
+ * A FilterSheet configuration: the category, attribute, and (brief-only) account
22
+ * groups for one view. Feed straight into `<FilterSheet {...preset} />`.
23
+ */
24
+ export interface FilterPreset {
25
+ categories: FilterSheetCategory[];
26
+ filters: FilterSheetFilter[];
27
+ sources?: FilterSheetSource[];
28
+ }
29
+
30
+ /**
31
+ * Content-type categories, mirroring the `MessageCategory` enum
32
+ * (@remit/remit-imap) by value. The leading "all" clears the category. Per
33
+ * message, not per mailbox — so they apply in the brief and an inbox alike.
34
+ */
35
+ const MESSAGE_CATEGORIES: FilterSheetCategory[] = [
36
+ { id: "all", label: "All", tone: "neutral" },
37
+ { id: "personal", label: "Personal", tone: "accent" },
38
+ { id: "transactional", label: "Transactional", tone: "positive" },
39
+ { id: "newsletter", label: "Newsletter", tone: "neutral" },
40
+ { id: "marketing", label: "Marketing", tone: "warning" },
41
+ { id: "social", label: "Social", tone: "warning" },
42
+ { id: "automated", label: "Automated", tone: "neutral" },
43
+ ];
44
+
45
+ const UNREAD: FilterSheetFilter = { id: "unread", label: "Unread" };
46
+ const FLAGGED: FilterSheetFilter = { id: "flagged", label: "Flagged" };
47
+ const HAS_ATTACHMENT: FilterSheetFilter = {
48
+ id: "attachment",
49
+ label: "Has attachment",
50
+ };
51
+
52
+ /** Accounts become a source group only when there is more than one to pick. */
53
+ function accountSources(
54
+ accounts: FilterAccount[],
55
+ ): FilterSheetSource[] | undefined {
56
+ if (accounts.length <= 1) return undefined;
57
+ return accounts.map((account) => ({
58
+ id: account.id,
59
+ label: account.label,
60
+ count: account.count,
61
+ active: account.active,
62
+ }));
63
+ }
64
+
65
+ /**
66
+ * Daily-brief filter: categories + the BriefSections chip set (the single source
67
+ * of truth for the brief's attribute chips), plus an account source group when
68
+ * more than one account feeds the brief (the brief aggregates them all).
69
+ */
70
+ export function briefFilterConfig(
71
+ accounts: FilterAccount[] = [],
72
+ ): FilterPreset {
73
+ return {
74
+ categories: [...MESSAGE_CATEGORIES],
75
+ filters: briefFilterChips,
76
+ sources: accountSources(accounts),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Inbox filter: categories + Unread/Flagged/Has attachment. No account group —
82
+ * an inbox is already scoped to a single account/mailbox.
83
+ */
84
+ export function inboxFilterConfig(): FilterPreset {
85
+ return {
86
+ categories: [...MESSAGE_CATEGORIES],
87
+ filters: [UNREAD, FLAGGED, HAS_ATTACHMENT],
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Flagged virtual-mailbox filter: categories + Unread/Has attachment. The view
93
+ * is already scoped to starred mail, so it never offers the redundant Flagged
94
+ * filter, and the flat starred list carries no account source group.
95
+ */
96
+ export function flaggedFilterConfig(): FilterPreset {
97
+ return {
98
+ categories: [...MESSAGE_CATEGORIES],
99
+ filters: [UNREAD, HAS_ATTACHMENT],
100
+ };
101
+ }