@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,625 @@
1
+ import {
2
+ AlertCircle,
3
+ AlertOctagon,
4
+ Archive,
5
+ BellOff,
6
+ BookmarkPlus,
7
+ ChevronDown,
8
+ ChevronRight,
9
+ FileText,
10
+ Folder,
11
+ Inbox,
12
+ Mails,
13
+ Search,
14
+ Send,
15
+ Sparkles,
16
+ Star,
17
+ Trash2,
18
+ X,
19
+ } from "lucide-react";
20
+ import type { ReactNode } from "react";
21
+ import { useState } from "react";
22
+ import { cn } from "../lib/cn.js";
23
+ import type {
24
+ AppShellProps,
25
+ NavAccount,
26
+ NavLinkComponent,
27
+ NavMailbox,
28
+ NavMailboxRole,
29
+ } from "./app-shell-types.js";
30
+
31
+ /* ------------------------------------------------------------------ */
32
+ /* Pane 1: navigation sidebar */
33
+ /* ------------------------------------------------------------------ */
34
+
35
+ function navItemClassName({
36
+ active,
37
+ dimmed,
38
+ indent,
39
+ }: {
40
+ active?: boolean;
41
+ dimmed?: boolean;
42
+ indent?: boolean;
43
+ }): string {
44
+ return cn(
45
+ "flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors",
46
+ indent && "pl-7",
47
+ active
48
+ ? "bg-accent-2-soft font-medium text-accent-2"
49
+ : "text-fg-muted hover:bg-surface hover:text-fg",
50
+ dimmed && "opacity-55",
51
+ );
52
+ }
53
+
54
+ function NavItemInner({
55
+ icon,
56
+ label,
57
+ count,
58
+ active,
59
+ }: {
60
+ icon?: ReactNode;
61
+ label: string;
62
+ count?: number;
63
+ active?: boolean;
64
+ }) {
65
+ return (
66
+ <>
67
+ {icon && (
68
+ <span
69
+ className={cn(
70
+ "shrink-0",
71
+ active ? "text-accent-2" : "text-fg-subtle",
72
+ )}
73
+ >
74
+ {icon}
75
+ </span>
76
+ )}
77
+ <span className="flex-1 truncate">{label}</span>
78
+ {count != null && count > 0 && (
79
+ <span
80
+ className={cn(
81
+ "text-2xs tabular-nums",
82
+ active ? "text-accent-2" : "text-fg-subtle",
83
+ )}
84
+ >
85
+ {count}
86
+ </span>
87
+ )}
88
+ </>
89
+ );
90
+ }
91
+
92
+ /**
93
+ * A navigation entry. Renders a real anchor through `linkComponent` when given a
94
+ * `navId` (preserving middle-click / deep-link / link a11y); otherwise a button
95
+ * with programmatic `onClick`. Non-navigational toggles never pass a navId.
96
+ */
97
+ function NavItem({
98
+ icon,
99
+ label,
100
+ count,
101
+ active,
102
+ dimmed,
103
+ indent,
104
+ navId,
105
+ linkComponent,
106
+ ariaLabel,
107
+ title,
108
+ onClick,
109
+ }: {
110
+ icon?: ReactNode;
111
+ label: string;
112
+ count?: number;
113
+ active?: boolean;
114
+ dimmed?: boolean;
115
+ indent?: boolean;
116
+ navId?: string;
117
+ linkComponent?: NavLinkComponent;
118
+ ariaLabel?: string;
119
+ title?: string;
120
+ onClick?: () => void;
121
+ }) {
122
+ const className = navItemClassName({ active, dimmed, indent });
123
+ const inner = (
124
+ <NavItemInner icon={icon} label={label} count={count} active={active} />
125
+ );
126
+
127
+ if (navId && linkComponent) {
128
+ return linkComponent({
129
+ navId,
130
+ className,
131
+ ariaLabel: ariaLabel ?? label,
132
+ title,
133
+ onClick,
134
+ children: inner,
135
+ });
136
+ }
137
+
138
+ return (
139
+ <button
140
+ type="button"
141
+ onClick={onClick}
142
+ className={className}
143
+ aria-label={ariaLabel}
144
+ title={title}
145
+ >
146
+ {inner}
147
+ </button>
148
+ );
149
+ }
150
+
151
+ /* System mailboxes render in a fixed, scannable order; a mailbox with no `role`
152
+ is a custom folder shown under a collapsible header. The role is the single
153
+ detection result computed by the web-client adapter — the kit never parses
154
+ raw IMAP SPECIAL-USE strings. Order matches the adapter's canonical priority
155
+ (Inbox, Flagged, Drafts, Sent, Archive, All, Junk, Trash). */
156
+ const ROLE_ORDER: Record<NavMailboxRole, number> = {
157
+ inbox: 0,
158
+ flagged: 1,
159
+ drafts: 2,
160
+ sent: 3,
161
+ archive: 4,
162
+ all: 5,
163
+ junk: 6,
164
+ trash: 7,
165
+ };
166
+
167
+ function roleIcon(role: NavMailboxRole): ReactNode {
168
+ switch (role) {
169
+ case "inbox":
170
+ return <Inbox className="size-4" />;
171
+ case "flagged":
172
+ return <Star className="size-4" />;
173
+ case "drafts":
174
+ return <FileText className="size-4" />;
175
+ case "sent":
176
+ return <Send className="size-4" />;
177
+ case "archive":
178
+ return <Archive className="size-4" />;
179
+ case "all":
180
+ return <Mails className="size-4" />;
181
+ case "junk":
182
+ return <AlertOctagon className="size-4" />;
183
+ case "trash":
184
+ return <Trash2 className="size-4" />;
185
+ }
186
+ }
187
+
188
+ const hasRole = (mb: NavMailbox): mb is NavMailbox & { role: NavMailboxRole } =>
189
+ mb.role != null;
190
+
191
+ const FOLDER_COLLAPSE_THRESHOLD = 8;
192
+
193
+ const FOLDER_OPEN_KEY = "remit.nav.folders.open.";
194
+ const ACCOUNT_OPEN_KEY = "remit.nav.account.open.";
195
+
196
+ /**
197
+ * Read a persisted open/closed flag. `fallback` applies only when nothing is
198
+ * stored yet. Both the account section and the custom-folders section default
199
+ * open: the folders list is independently capped at FOLDER_COLLAPSE_THRESHOLD
200
+ * with a "Show all", so an open section never pushes the system block far off
201
+ * screen — making default-open the more discoverable choice.
202
+ *
203
+ * Exported for unit testing the persistence contract.
204
+ */
205
+ export function readOpen(
206
+ prefix: string,
207
+ id: string,
208
+ fallback: boolean,
209
+ ): boolean {
210
+ if (typeof localStorage === "undefined") return fallback;
211
+ const stored = localStorage.getItem(prefix + id);
212
+ if (stored === null) return fallback;
213
+ return stored !== "0";
214
+ }
215
+
216
+ export function writeOpen(prefix: string, id: string, open: boolean): void {
217
+ if (typeof localStorage === "undefined") return;
218
+ localStorage.setItem(prefix + id, open ? "1" : "0");
219
+ }
220
+
221
+ export const NAV_FOLDER_OPEN_KEY = FOLDER_OPEN_KEY;
222
+ export const NAV_ACCOUNT_OPEN_KEY = ACCOUNT_OPEN_KEY;
223
+
224
+ /**
225
+ * One row in the "Saved searches" group: a plain query string (local-only MVP
226
+ * — no separate label, see doc/design/flows/06-search.md). Hover reveals a
227
+ * remove control, mirroring `SearchTokenChip`'s dismiss affordance.
228
+ */
229
+ function SavedSearchRow({
230
+ query,
231
+ onSelect,
232
+ onRemove,
233
+ }: {
234
+ query: string;
235
+ onSelect?: () => void;
236
+ onRemove?: () => void;
237
+ }) {
238
+ return (
239
+ <div className="group flex w-full items-center gap-1 rounded-md pr-1 hover:bg-surface">
240
+ <button
241
+ type="button"
242
+ onClick={onSelect}
243
+ className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1 text-left text-sm text-fg-muted transition-colors hover:text-fg"
244
+ >
245
+ <Search className="size-4 shrink-0 text-fg-subtle" />
246
+ <span className="min-w-0 flex-1 truncate">{query}</span>
247
+ </button>
248
+ {onRemove && (
249
+ <button
250
+ type="button"
251
+ onClick={onRemove}
252
+ aria-label={`Remove saved search: ${query}`}
253
+ className="shrink-0 rounded-full p-1 text-fg-subtle opacity-0 transition-opacity hover:bg-surface-sunken hover:text-fg group-hover:opacity-100 focus-visible:opacity-100"
254
+ >
255
+ <X className="size-3.5" />
256
+ </button>
257
+ )}
258
+ </div>
259
+ );
260
+ }
261
+
262
+ /**
263
+ * The reserved "Saved searches" group (#428 follow-up): a "Save «query»" row
264
+ * when the active search isn't saved yet, then every saved query as a
265
+ * removable row. Renders nothing when there is neither an active query to
266
+ * save nor any saved searches — an always-empty group would just be noise.
267
+ */
268
+ function SavedSearchesGroup({
269
+ savedSearches,
270
+ saveableQuery,
271
+ onSelectSavedSearch,
272
+ onRemoveSavedSearch,
273
+ onSaveCurrentSearch,
274
+ }: {
275
+ savedSearches: string[];
276
+ saveableQuery?: string;
277
+ onSelectSavedSearch?: (query: string) => void;
278
+ onRemoveSavedSearch?: (query: string) => void;
279
+ onSaveCurrentSearch?: () => void;
280
+ }) {
281
+ if (savedSearches.length === 0 && !saveableQuery) return null;
282
+ return (
283
+ <div className="mt-3">
284
+ <div className="px-2 pb-1 text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
285
+ Saved searches
286
+ </div>
287
+ {saveableQuery && (
288
+ <button
289
+ type="button"
290
+ onClick={onSaveCurrentSearch}
291
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm text-accent transition-colors hover:bg-surface"
292
+ >
293
+ <BookmarkPlus className="size-4 shrink-0" />
294
+ <span className="min-w-0 flex-1 truncate">
295
+ Save &ldquo;{saveableQuery}&rdquo;
296
+ </span>
297
+ </button>
298
+ )}
299
+ {savedSearches.map((query) => (
300
+ <SavedSearchRow
301
+ key={query}
302
+ query={query}
303
+ onSelect={() => onSelectSavedSearch?.(query)}
304
+ onRemove={
305
+ onRemoveSavedSearch ? () => onRemoveSavedSearch(query) : undefined
306
+ }
307
+ />
308
+ ))}
309
+ </div>
310
+ );
311
+ }
312
+
313
+ function AccountNav({
314
+ account,
315
+ selectedNavId,
316
+ onSelectNav,
317
+ linkComponent,
318
+ }: {
319
+ account: NavAccount;
320
+ selectedNavId: string;
321
+ onSelectNav?: (id: string) => void;
322
+ linkComponent?: NavLinkComponent;
323
+ }) {
324
+ const [accountOpen, setAccountOpen] = useState(() =>
325
+ readOpen(ACCOUNT_OPEN_KEY, account.id, true),
326
+ );
327
+ const [foldersOpen, setFoldersOpen] = useState(() =>
328
+ readOpen(FOLDER_OPEN_KEY, account.id, true),
329
+ );
330
+ const [showAllFolders, setShowAllFolders] = useState(false);
331
+
332
+ const toggleAccount = () => {
333
+ setAccountOpen((open) => {
334
+ const next = !open;
335
+ writeOpen(ACCOUNT_OPEN_KEY, account.id, next);
336
+ return next;
337
+ });
338
+ };
339
+
340
+ const toggleFolders = () => {
341
+ setFoldersOpen((open) => {
342
+ const next = !open;
343
+ writeOpen(FOLDER_OPEN_KEY, account.id, next);
344
+ return next;
345
+ });
346
+ };
347
+
348
+ const system = account.mailboxes
349
+ .filter(hasRole)
350
+ .sort((a, b) => ROLE_ORDER[a.role] - ROLE_ORDER[b.role]);
351
+ const folders = account.mailboxes.filter((mb) => !hasRole(mb));
352
+ const visibleFolders =
353
+ showAllFolders || folders.length <= FOLDER_COLLAPSE_THRESHOLD
354
+ ? folders
355
+ : folders.slice(0, FOLDER_COLLAPSE_THRESHOLD);
356
+ const hiddenCount = folders.length - visibleFolders.length;
357
+
358
+ const status = account.status ?? "ready";
359
+ const isEmpty =
360
+ status === "ready" && system.length === 0 && folders.length === 0;
361
+
362
+ return (
363
+ <div className="mt-3">
364
+ <button
365
+ type="button"
366
+ onClick={toggleAccount}
367
+ aria-expanded={accountOpen}
368
+ className={cn(
369
+ "flex w-full items-center gap-1.5 px-2 pb-1 text-left transition-colors hover:text-fg",
370
+ account.muted && "opacity-55",
371
+ )}
372
+ >
373
+ {accountOpen ? (
374
+ <ChevronDown className="size-3 shrink-0 text-fg-subtle" />
375
+ ) : (
376
+ <ChevronRight className="size-3 shrink-0 text-fg-subtle" />
377
+ )}
378
+ <span className="truncate text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
379
+ {account.label}
380
+ </span>
381
+ {account.muted && (
382
+ <>
383
+ <BellOff className="size-3 shrink-0 text-fg-subtle" />
384
+ <span className="text-2xs text-fg-subtle">muted</span>
385
+ </>
386
+ )}
387
+ </button>
388
+
389
+ {accountOpen && (
390
+ <>
391
+ {status === "loading" && (
392
+ <div className="px-2 py-1 text-sm text-fg-muted">Loading…</div>
393
+ )}
394
+
395
+ {status === "error" && (
396
+ <div
397
+ role="alert"
398
+ className="mx-2 my-1 flex items-start gap-2 rounded-md border border-danger/40 bg-danger-soft px-2 py-1.5 text-sm"
399
+ >
400
+ <AlertCircle
401
+ className="mt-0.5 size-4 shrink-0 text-danger"
402
+ aria-hidden="true"
403
+ />
404
+ <div className="min-w-0 flex-1">
405
+ <p className="font-medium text-danger">
406
+ Couldn't load mailboxes
407
+ </p>
408
+ </div>
409
+ {account.onRetry && (
410
+ <button
411
+ type="button"
412
+ onClick={account.onRetry}
413
+ className="shrink-0 text-sm font-medium text-accent hover:underline"
414
+ >
415
+ Retry
416
+ </button>
417
+ )}
418
+ </div>
419
+ )}
420
+
421
+ {isEmpty && (
422
+ <div className="px-2 py-1 text-sm text-fg-muted">No mailboxes</div>
423
+ )}
424
+
425
+ {status === "ready" && (
426
+ <>
427
+ {system.map((mb) => (
428
+ <NavItem
429
+ key={mb.id}
430
+ navId={mb.id}
431
+ linkComponent={linkComponent}
432
+ icon={roleIcon(mb.role)}
433
+ label={mb.name}
434
+ ariaLabel={mb.name}
435
+ title={mb.fullPath ?? mb.name}
436
+ count={mb.unseen}
437
+ active={selectedNavId === mb.id}
438
+ dimmed={account.muted}
439
+ onClick={() => onSelectNav?.(mb.id)}
440
+ />
441
+ ))}
442
+
443
+ {account.outboxPending !== undefined && (
444
+ <NavItem
445
+ navId="outbox"
446
+ linkComponent={linkComponent}
447
+ icon={<Send className="size-4" />}
448
+ label="Outbox"
449
+ ariaLabel="Outbox"
450
+ count={
451
+ account.outboxPending > 0
452
+ ? account.outboxPending
453
+ : undefined
454
+ }
455
+ active={selectedNavId === "outbox"}
456
+ dimmed={account.muted}
457
+ onClick={() => onSelectNav?.("outbox")}
458
+ />
459
+ )}
460
+
461
+ {folders.length > 0 && (
462
+ <>
463
+ <button
464
+ type="button"
465
+ onClick={toggleFolders}
466
+ aria-expanded={foldersOpen}
467
+ className="mt-1 flex w-full items-center gap-1 px-2 py-1 text-left text-2xs font-semibold uppercase tracking-wider text-fg-subtle transition-colors hover:text-fg"
468
+ >
469
+ {foldersOpen ? (
470
+ <ChevronDown className="size-3 shrink-0" />
471
+ ) : (
472
+ <ChevronRight className="size-3 shrink-0" />
473
+ )}
474
+ <span className="flex-1">Folders</span>
475
+ <span className="tabular-nums opacity-70">
476
+ {folders.length}
477
+ </span>
478
+ </button>
479
+ {foldersOpen && (
480
+ <>
481
+ {visibleFolders.map((mb) => (
482
+ <NavItem
483
+ key={mb.id}
484
+ navId={mb.id}
485
+ linkComponent={linkComponent}
486
+ icon={<Folder className="size-4" />}
487
+ label={mb.name}
488
+ ariaLabel={mb.name}
489
+ title={mb.fullPath ?? mb.name}
490
+ count={mb.unseen}
491
+ active={selectedNavId === mb.id}
492
+ dimmed={account.muted}
493
+ onClick={() => onSelectNav?.(mb.id)}
494
+ />
495
+ ))}
496
+ {(hiddenCount > 0 || showAllFolders) &&
497
+ folders.length > FOLDER_COLLAPSE_THRESHOLD && (
498
+ <button
499
+ type="button"
500
+ onClick={() => setShowAllFolders((all) => !all)}
501
+ className="ml-7 flex items-center px-2 py-1 text-2xs font-medium text-accent transition-colors hover:underline"
502
+ >
503
+ {showAllFolders
504
+ ? "Show less"
505
+ : `Show all (${folders.length})`}
506
+ </button>
507
+ )}
508
+ </>
509
+ )}
510
+ </>
511
+ )}
512
+ </>
513
+ )}
514
+ </>
515
+ )}
516
+ </div>
517
+ );
518
+ }
519
+
520
+ export interface NavSidebarProps
521
+ extends Pick<
522
+ AppShellProps,
523
+ "accounts" | "selectedNavId" | "briefUnseen" | "onSelectNav"
524
+ > {
525
+ /**
526
+ * "desktop" (default) wraps the nav in a full-height aside — the Pane 1
527
+ * column in the 4-pane shell. "drawer" renders the nav body only, letting
528
+ * the mobile Drawer panel be the outer container.
529
+ */
530
+ variant?: "desktop" | "drawer";
531
+ /**
532
+ * Renders each navigation entry as a real anchor (router `<Link>`). Omit to
533
+ * fall back to programmatic buttons (static stories / AppShell preview).
534
+ */
535
+ linkComponent?: NavLinkComponent;
536
+ /** Every saved query, most recently saved first. See `SavedSearchesGroup`. */
537
+ savedSearches?: string[];
538
+ /**
539
+ * The active search query, when it's non-empty and not already saved — shows
540
+ * the "Save «query»" row. Omit (or pass an already-saved query) to hide it.
541
+ */
542
+ saveableQuery?: string;
543
+ onSelectSavedSearch?: (query: string) => void;
544
+ onRemoveSavedSearch?: (query: string) => void;
545
+ onSaveCurrentSearch?: () => void;
546
+ }
547
+
548
+ export function NavSidebar({
549
+ accounts,
550
+ selectedNavId,
551
+ briefUnseen,
552
+ onSelectNav,
553
+ variant = "desktop",
554
+ linkComponent,
555
+ savedSearches = [],
556
+ saveableQuery,
557
+ onSelectSavedSearch,
558
+ onRemoveSavedSearch,
559
+ onSaveCurrentSearch,
560
+ }: NavSidebarProps) {
561
+ const navBody = (
562
+ <nav
563
+ className={
564
+ variant === "drawer" ? "px-2 py-2" : "flex-1 overflow-y-auto px-2 py-2"
565
+ }
566
+ aria-label="Mailboxes"
567
+ >
568
+ <NavItem
569
+ navId="brief"
570
+ linkComponent={linkComponent}
571
+ icon={<Sparkles className="size-4" />}
572
+ label="Daily brief"
573
+ ariaLabel="Daily brief"
574
+ count={briefUnseen}
575
+ active={selectedNavId === "brief"}
576
+ onClick={() => onSelectNav?.("brief")}
577
+ />
578
+
579
+ <NavItem
580
+ navId="flagged"
581
+ linkComponent={linkComponent}
582
+ icon={<Star className="size-4" />}
583
+ label="Flagged"
584
+ ariaLabel="Flagged"
585
+ active={selectedNavId === "flagged"}
586
+ onClick={() => onSelectNav?.("flagged")}
587
+ />
588
+
589
+ <SavedSearchesGroup
590
+ savedSearches={savedSearches}
591
+ saveableQuery={saveableQuery}
592
+ onSelectSavedSearch={onSelectSavedSearch}
593
+ onRemoveSavedSearch={onRemoveSavedSearch}
594
+ onSaveCurrentSearch={onSaveCurrentSearch}
595
+ />
596
+
597
+ {accounts.length === 0 ? (
598
+ <div className="px-2 py-4 text-center text-sm text-fg-muted">
599
+ No accounts configured
600
+ </div>
601
+ ) : (
602
+ accounts.map((account) => (
603
+ <AccountNav
604
+ key={account.id}
605
+ account={account}
606
+ selectedNavId={selectedNavId}
607
+ onSelectNav={onSelectNav}
608
+ linkComponent={linkComponent}
609
+ />
610
+ ))
611
+ )}
612
+ </nav>
613
+ );
614
+
615
+ if (variant === "drawer") return navBody;
616
+
617
+ return (
618
+ <aside className="flex h-full w-full flex-col bg-surface-sunken">
619
+ {/* no toolbar over the sidebar (Apple Mail-style): nav content
620
+ starts at the top; the datum bar exists only over the
621
+ list/reading/intelligence panes */}
622
+ {navBody}
623
+ </aside>
624
+ );
625
+ }
@@ -0,0 +1,61 @@
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 { OutboxRow } from "./outbox-row.js";
6
+
7
+ const baseProps = {
8
+ recipients: "alex@example.com",
9
+ subject: "Hello",
10
+ time: "9:42",
11
+ onSelect: () => undefined,
12
+ onEdit: () => undefined,
13
+ onDelete: () => undefined,
14
+ };
15
+
16
+ describe("OutboxRow", () => {
17
+ it("renders recipients, subject and the status label", () => {
18
+ const html = renderToString(
19
+ createElement(OutboxRow, { ...baseProps, status: "queued" }),
20
+ );
21
+ assert.match(html, /alex@example\.com/);
22
+ assert.match(html, /Hello/);
23
+ assert.match(html, /Queued/);
24
+ });
25
+
26
+ it("hides the action cluster for in-flight statuses", () => {
27
+ const html = renderToString(
28
+ createElement(OutboxRow, { ...baseProps, status: "sending" }),
29
+ );
30
+ assert.doesNotMatch(html, /aria-label="Delete message"/);
31
+ });
32
+
33
+ it("shows retry, edit and delete for a failed row, never disabled", () => {
34
+ const html = renderToString(
35
+ createElement(OutboxRow, {
36
+ ...baseProps,
37
+ status: "failed",
38
+ error: "SMTP refused",
39
+ onRetry: () => undefined,
40
+ }),
41
+ );
42
+ assert.match(html, /aria-label="Retry sending"/);
43
+ assert.match(html, /aria-label="Edit as draft"/);
44
+ assert.match(html, /aria-label="Delete message"/);
45
+ assert.match(html, /SMTP refused/);
46
+ assert.doesNotMatch(html, /disabled=""/);
47
+ });
48
+
49
+ it("omits retry for a blocked row but keeps edit and delete", () => {
50
+ const html = renderToString(
51
+ createElement(OutboxRow, {
52
+ ...baseProps,
53
+ status: "blocked",
54
+ error: "no SMTP host",
55
+ }),
56
+ );
57
+ assert.doesNotMatch(html, /aria-label="Retry sending"/);
58
+ assert.match(html, /aria-label="Edit as draft"/);
59
+ assert.match(html, /aria-label="Delete message"/);
60
+ });
61
+ });