@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,316 @@
1
+ import { useCallback, useState } from "react";
2
+ import { cn } from "../lib/cn.js";
3
+ import { isSelfRowActivation } from "../lib/row-keyboard.js";
4
+ import { Badge } from "./badge.js";
5
+
6
+ export type CategoryTone =
7
+ | "neutral"
8
+ | "accent"
9
+ | "positive"
10
+ | "warning"
11
+ | "danger";
12
+
13
+ export interface FilterSheetCategory {
14
+ id: string;
15
+ label: string;
16
+ tone?: CategoryTone;
17
+ }
18
+
19
+ export interface FilterSheetFilter {
20
+ id: string;
21
+ label: string;
22
+ }
23
+
24
+ export interface FilterSheetSource {
25
+ id: string;
26
+ label: string;
27
+ count?: number;
28
+ active?: boolean;
29
+ }
30
+
31
+ export interface FilterSheetProps {
32
+ /** Available category options. The first one is treated as "all" (clears category). */
33
+ categories: FilterSheetCategory[];
34
+ /** Available attribute filters for multi-toggle. */
35
+ filters: FilterSheetFilter[];
36
+ /**
37
+ * Source/account pills. Single-select scoping the brief to one account.
38
+ * Omitted (or a single entry) hides the source row.
39
+ */
40
+ sources?: FilterSheetSource[];
41
+ /** Note rendered alongside the source pills (e.g. "+2 muted"). */
42
+ sourcesNote?: string;
43
+ /** Currently selected category id. */
44
+ selectedCategory: string;
45
+ /** Currently active filter ids. */
46
+ activeFilters: ReadonlySet<string>;
47
+ /**
48
+ * Whether the filter panel is expanded.
49
+ * When omitted the component manages this state internally (collapsed by default).
50
+ */
51
+ expanded?: boolean;
52
+ /** Called when the user selects a category. */
53
+ onSelectCategory: (id: string) => void;
54
+ /** Called when the user selects a source/account pill. */
55
+ onSelectSource?: (id: string) => void;
56
+ /** Called when the user toggles a filter on or off. */
57
+ onToggleFilter: (id: string) => void;
58
+ /** Called when the user clears all selections back to defaults. */
59
+ onClear: () => void;
60
+ /**
61
+ * Called when the expanded state changes.
62
+ * Required when `expanded` is a controlled prop.
63
+ */
64
+ onExpandedChange?: (expanded: boolean) => void;
65
+ /**
66
+ * The list rendered below the filter. The expanded panel is inline: it pushes
67
+ * this content down (the list reflows), it does not overlay it.
68
+ */
69
+ children?: React.ReactNode;
70
+ }
71
+
72
+ function ChevronDown({ className }: { className?: string }) {
73
+ return (
74
+ <svg
75
+ viewBox="0 0 12 12"
76
+ className={className}
77
+ fill="none"
78
+ stroke="currentColor"
79
+ strokeWidth={1.5}
80
+ aria-hidden="true"
81
+ >
82
+ <path d="M2 4l4 4 4-4" />
83
+ </svg>
84
+ );
85
+ }
86
+
87
+ function Close({ className }: { className?: string }) {
88
+ return (
89
+ <svg
90
+ viewBox="0 0 12 12"
91
+ className={className}
92
+ fill="none"
93
+ stroke="currentColor"
94
+ strokeWidth={1.5}
95
+ aria-hidden="true"
96
+ >
97
+ <path d="M3 3l6 6M9 3l-6 6" />
98
+ </svg>
99
+ );
100
+ }
101
+
102
+ export function FilterSheet({
103
+ categories,
104
+ filters,
105
+ sources,
106
+ sourcesNote,
107
+ selectedCategory,
108
+ activeFilters,
109
+ expanded: expandedProp,
110
+ onSelectCategory,
111
+ onSelectSource,
112
+ onToggleFilter,
113
+ onClear,
114
+ onExpandedChange,
115
+ children,
116
+ }: FilterSheetProps) {
117
+ const isControlled = expandedProp !== undefined;
118
+ const [internalOpen, setInternalOpen] = useState(false);
119
+
120
+ const open = isControlled ? expandedProp : internalOpen;
121
+
122
+ const setOpen = useCallback(
123
+ (next: boolean) => {
124
+ if (!isControlled) setInternalOpen(next);
125
+ onExpandedChange?.(next);
126
+ },
127
+ [isControlled, onExpandedChange],
128
+ );
129
+
130
+ const defaultCategory = categories[0];
131
+ const isDefault =
132
+ defaultCategory !== undefined && selectedCategory === defaultCategory.id;
133
+
134
+ const defaultSource = sources?.[0];
135
+ const activeSource = sources?.find((s) => s.active);
136
+ const isDefaultSource =
137
+ defaultSource === undefined || activeSource?.id === defaultSource.id;
138
+
139
+ const hasActive =
140
+ !isDefault ||
141
+ activeFilters.size > 0 ||
142
+ (!isDefaultSource && !!activeSource);
143
+
144
+ const clearSource = useCallback(() => {
145
+ if (defaultSource) onSelectSource?.(defaultSource.id);
146
+ }, [defaultSource, onSelectSource]);
147
+
148
+ const clearAll = useCallback(() => {
149
+ onClear();
150
+ clearSource();
151
+ }, [onClear, clearSource]);
152
+
153
+ const activeCategory = categories.find((c) => c.id === selectedCategory);
154
+
155
+ const summaryChips = hasActive ? (
156
+ <>
157
+ {!isDefaultSource && activeSource && (
158
+ <span className="inline-flex items-center rounded-full border border-accent-2 bg-accent-2-soft px-2 py-0.5 text-2xs font-medium text-accent-2">
159
+ {activeSource.label}
160
+ </span>
161
+ )}
162
+ {!isDefault && activeCategory && (
163
+ <Badge tone={activeCategory.tone ?? "neutral"}>
164
+ {activeCategory.label}
165
+ </Badge>
166
+ )}
167
+ {filters
168
+ .filter((f) => activeFilters.has(f.id))
169
+ .map((f) => (
170
+ <span
171
+ key={f.id}
172
+ className="inline-flex items-center rounded-full border border-accent-2 bg-accent-2-soft px-2 py-0.5 text-2xs font-medium text-accent-2"
173
+ >
174
+ {f.label}
175
+ </span>
176
+ ))}
177
+ </>
178
+ ) : (
179
+ <span className="text-2xs text-fg-subtle">Filters</span>
180
+ );
181
+
182
+ const sourceRow = sources && sources.length > 1 && (
183
+ <div className="flex flex-wrap items-center gap-1.5 border-b border-line px-row-inset pb-2 pt-2">
184
+ {sources.map((source) => (
185
+ <button
186
+ key={source.id}
187
+ type="button"
188
+ onClick={() => onSelectSource?.(source.id)}
189
+ className={cn(
190
+ "flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-2xs transition-colors",
191
+ source.active
192
+ ? "border-accent-2 bg-accent-2-soft font-medium text-accent-2"
193
+ : "border-line text-fg-muted hover:border-line-strong",
194
+ )}
195
+ >
196
+ {source.label}
197
+ {source.count != null && source.count > 0 && (
198
+ <span className="tabular-nums opacity-70">{source.count}</span>
199
+ )}
200
+ </button>
201
+ ))}
202
+ {sourcesNote && (
203
+ <span className="ml-auto shrink-0 text-2xs text-fg-subtle">
204
+ {sourcesNote}
205
+ </span>
206
+ )}
207
+ </div>
208
+ );
209
+
210
+ const categoryRow = (
211
+ <div className="flex flex-wrap items-center gap-1.5 border-b border-line px-row-inset pb-1.5 pt-1.5">
212
+ {categories.map((cat) => {
213
+ const selected = selectedCategory === cat.id;
214
+ return (
215
+ <button
216
+ key={cat.id}
217
+ type="button"
218
+ onClick={() => onSelectCategory(cat.id)}
219
+ className={cn(
220
+ "shrink-0 rounded-full transition-opacity",
221
+ selected
222
+ ? "opacity-100 ring-1 ring-accent-2"
223
+ : "opacity-60 hover:opacity-100",
224
+ )}
225
+ >
226
+ <Badge tone={cat.tone ?? "neutral"}>{cat.label}</Badge>
227
+ </button>
228
+ );
229
+ })}
230
+ </div>
231
+ );
232
+
233
+ const filterRow = (
234
+ <div className="flex flex-wrap items-center gap-1.5 border-b border-line px-row-inset pb-2 pt-1.5">
235
+ {filters.map((f) => {
236
+ const on = activeFilters.has(f.id);
237
+ return (
238
+ <button
239
+ key={f.id}
240
+ type="button"
241
+ onClick={() => onToggleFilter(f.id)}
242
+ className={cn(
243
+ "shrink-0 rounded-full border px-2.5 py-0.5 text-2xs transition-colors",
244
+ on
245
+ ? "border-accent-2 bg-accent-2-soft font-medium text-accent-2"
246
+ : "border-line text-fg-muted hover:border-line-strong",
247
+ )}
248
+ >
249
+ {f.label}
250
+ </button>
251
+ );
252
+ })}
253
+ {hasActive && (
254
+ <button
255
+ type="button"
256
+ onClick={clearAll}
257
+ className="ml-auto flex items-center gap-1 px-1 text-2xs text-fg-subtle hover:text-fg-muted"
258
+ >
259
+ <Close className="size-3" />
260
+ Clear
261
+ </button>
262
+ )}
263
+ </div>
264
+ );
265
+
266
+ return (
267
+ <div className="flex h-full w-full flex-col overflow-hidden">
268
+ {/* The toggle is a div-button (not a real <button>) so the Clear
269
+ control can be a real nested <button> without invalid
270
+ button-in-button nesting. */}
271
+ {/* biome-ignore lint/a11y/useSemanticElements: nested <button> inside would be invalid button-in-button */}
272
+ <div
273
+ role="button"
274
+ tabIndex={0}
275
+ aria-expanded={open}
276
+ aria-label={open ? "Collapse filters" : "Expand filters"}
277
+ onClick={() => setOpen(!open)}
278
+ onKeyDown={(e) => {
279
+ if (!isSelfRowActivation(e)) return;
280
+ e.preventDefault();
281
+ setOpen(!open);
282
+ }}
283
+ className="flex h-section-row w-full shrink-0 cursor-pointer items-center gap-1.5 overflow-x-hidden border-b border-line bg-surface-sunken px-row-inset text-left hover:bg-surface"
284
+ >
285
+ {summaryChips}
286
+ {hasActive && (
287
+ <button
288
+ type="button"
289
+ aria-label="Clear filters"
290
+ onClick={(e) => {
291
+ e.stopPropagation();
292
+ clearAll();
293
+ }}
294
+ className="flex size-6 items-center justify-center text-fg-subtle hover:text-fg-muted"
295
+ >
296
+ <Close className="size-3" />
297
+ </button>
298
+ )}
299
+ <ChevronDown
300
+ className={cn(
301
+ "ml-auto size-3 shrink-0 text-fg-subtle transition-transform duration-200",
302
+ open ? "rotate-180" : "rotate-0",
303
+ )}
304
+ />
305
+ </div>
306
+ {open && (
307
+ <div className="shrink-0">
308
+ {sourceRow}
309
+ {categoryRow}
310
+ {filterRow}
311
+ </div>
312
+ )}
313
+ <div className="flex-1 overflow-y-auto">{children}</div>
314
+ </div>
315
+ );
316
+ }
@@ -0,0 +1,66 @@
1
+ import {
2
+ Archive,
3
+ FileText,
4
+ Inbox,
5
+ Mails,
6
+ OctagonAlert,
7
+ Send,
8
+ Star,
9
+ Trash2,
10
+ } from "lucide-react";
11
+ import type { ReactNode } from "react";
12
+
13
+ /* ------------------------------------------------------------------ */
14
+ /* Folder role vocabulary (RFC 032 exclusive-folder-appointment, */
15
+ /* issue #976). The canonical roles are a fixed anchor set the user */
16
+ /* appoints an existing folder to — see RoleAppointmentList. A folder */
17
+ /* the account exposes but nobody appointed has no role at all; there */
18
+ /* is no "custom" role member here (unlike the per-folder role picker */
19
+ /* this replaced) because a plain folder simply carries no role. */
20
+ /* ------------------------------------------------------------------ */
21
+
22
+ export type FolderRole =
23
+ | "inbox"
24
+ | "drafts"
25
+ | "sent"
26
+ | "archive"
27
+ | "junk"
28
+ | "trash"
29
+ | "flagged"
30
+ | "all";
31
+
32
+ /* Canonical English labels, matching the `sidebar.${role}` i18n keys
33
+ (mail.json). At the app boundary these get localized; the kit ships the
34
+ English defaults so stories read true. */
35
+ const ROLE_LABEL: Record<FolderRole, string> = {
36
+ inbox: "Inbox",
37
+ drafts: "Drafts",
38
+ sent: "Sent",
39
+ archive: "Archive",
40
+ junk: "Spam",
41
+ trash: "Trash",
42
+ flagged: "Starred",
43
+ all: "All Mail",
44
+ };
45
+
46
+ /** Canonical label for a role. */
47
+ export function canonicalRoleLabel(role: FolderRole): string {
48
+ return ROLE_LABEL[role];
49
+ }
50
+
51
+ /** Leaf segment of a provider path (`INBOX/Spam` → `Spam`). */
52
+ export function providerLeaf(providerPath: string): string {
53
+ const parts = providerPath.split("/");
54
+ return parts[parts.length - 1] || providerPath;
55
+ }
56
+
57
+ export function roleIcon(role: FolderRole): ReactNode {
58
+ if (role === "inbox") return <Inbox className="size-4" />;
59
+ if (role === "drafts") return <FileText className="size-4" />;
60
+ if (role === "sent") return <Send className="size-4" />;
61
+ if (role === "archive") return <Archive className="size-4" />;
62
+ if (role === "junk") return <OctagonAlert className="size-4" />;
63
+ if (role === "trash") return <Trash2 className="size-4" />;
64
+ if (role === "flagged") return <Star className="size-4" />;
65
+ return <Mails className="size-4" />;
66
+ }
@@ -0,0 +1,44 @@
1
+ import type { InputHTMLAttributes, ReactNode, Ref } from "react";
2
+ import { cn } from "../lib/cn.js";
3
+
4
+ export type InputVariant = "default" | "inline";
5
+
6
+ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
7
+ /** Optional leading icon (lucide-react element). */
8
+ icon?: ReactNode;
9
+ /**
10
+ * `default` renders the boxed field (border, sunken surface, focus ring).
11
+ * `inline` is a borderless, transparent field for inline filter/search
12
+ * rows where the surrounding container owns the chrome.
13
+ */
14
+ variant?: InputVariant;
15
+ /** Forwarded to the underlying `<input>`. */
16
+ ref?: Ref<HTMLInputElement>;
17
+ }
18
+
19
+ const wrapperVariants: Record<InputVariant, string> = {
20
+ default: cn(
21
+ "flex h-9 items-center gap-2 rounded-md border border-line bg-surface-sunken px-3 text-sm",
22
+ "focus-within:border-line-strong focus-within:ring-2 focus-within:ring-ring/30 transition-colors",
23
+ ),
24
+ inline: "flex items-center gap-2 bg-transparent text-sm",
25
+ };
26
+
27
+ export function Input({
28
+ icon,
29
+ className,
30
+ variant = "default",
31
+ ref,
32
+ ...props
33
+ }: InputProps) {
34
+ return (
35
+ <div className={cn(wrapperVariants[variant], className)}>
36
+ {icon && <span className="text-fg-subtle shrink-0">{icon}</span>}
37
+ <input
38
+ ref={ref}
39
+ className="min-w-0 flex-1 bg-transparent text-fg placeholder:text-fg-subtle outline-none"
40
+ {...props}
41
+ />
42
+ </div>
43
+ );
44
+ }
@@ -0,0 +1,149 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import type { IntelligenceData } from "./intelligence-panel.js";
3
+ import { IntelligencePanel } from "./intelligence-panel.js";
4
+
5
+ const base: IntelligenceData = {
6
+ sender: {
7
+ name: "Alex Rivera",
8
+ email: "alex@example.com",
9
+ trust: "wellknown",
10
+ firstSeenLabel: "Jan 2025",
11
+ },
12
+ authenticity: {
13
+ verdict: "aligned",
14
+ fromDomain: "example.com",
15
+ dkimDomain: "example.com",
16
+ summary: "We verified this message was really sent by example.com.",
17
+ },
18
+ category: { value: "Personal" },
19
+ similar: [],
20
+ };
21
+
22
+ const meta: Meta<typeof IntelligencePanel> = {
23
+ title: "Screens/Kit/IntelligencePanel",
24
+ component: IntelligencePanel,
25
+ parameters: { layout: "centered" },
26
+ };
27
+ export default meta;
28
+
29
+ type Story = StoryObj<typeof IntelligencePanel>;
30
+
31
+ export const Aligned: Story = {
32
+ args: { data: base },
33
+ };
34
+
35
+ export const CautionNoSignal: Story = {
36
+ args: {
37
+ data: {
38
+ ...base,
39
+ sender: {
40
+ name: "Notifications",
41
+ email: "no-reply@unknown-source.example",
42
+ trust: "unknown",
43
+ firstSeenLabel: "today",
44
+ },
45
+ authenticity: {
46
+ verdict: "caution",
47
+ fromDomain: "unknown-source.example",
48
+ summary:
49
+ "We can't verify the sender of this email, which could mean it's from an insecure source.",
50
+ },
51
+ },
52
+ },
53
+ };
54
+
55
+ export const Impersonation: Story = {
56
+ args: {
57
+ data: {
58
+ ...base,
59
+ sender: {
60
+ name: "Your Bank",
61
+ email: "security@your-bank.example",
62
+ trust: "unknown",
63
+ firstSeenLabel: "today",
64
+ },
65
+ category: { value: "Phishing" },
66
+ authenticity: {
67
+ verdict: "mismatch",
68
+ fromDomain: "your-bank.example",
69
+ dkimDomain: "mailer.suspicious.example",
70
+ claimedBrand: "Your Bank",
71
+ summary:
72
+ 'The display name claims "Your Bank", but this message was actually sent from mailer.suspicious.example — not your-bank.example. Real senders use their own address.',
73
+ similarCount: 4,
74
+ },
75
+ },
76
+ },
77
+ };
78
+
79
+ export const UnreadableSender: Story = {
80
+ args: {
81
+ data: {
82
+ ...base,
83
+ sender: {
84
+ name: "Mailbox Admin",
85
+ email: "missing_mailbox@missing_domain",
86
+ trust: "unknown",
87
+ firstSeenLabel: "today",
88
+ addressUnverified: true,
89
+ },
90
+ category: { value: "Phishing" },
91
+ authenticity: {
92
+ verdict: "mismatch",
93
+ fromDomain: "",
94
+ addressUnreadable: true,
95
+ summary:
96
+ "We couldn't read this sender's address, so we can't confirm who really sent this message.",
97
+ },
98
+ },
99
+ },
100
+ };
101
+
102
+ export const WithSimilarMessages: Story = {
103
+ args: {
104
+ similarLinkComponent: ({
105
+ mailboxId,
106
+ messageId,
107
+ className,
108
+ ariaLabel,
109
+ children,
110
+ }) => (
111
+ <a
112
+ href={`/mail/${mailboxId}?selectedMessageId=${messageId}`}
113
+ className={className}
114
+ aria-label={ariaLabel}
115
+ >
116
+ {children}
117
+ </a>
118
+ ),
119
+ data: {
120
+ ...base,
121
+ similar: [
122
+ {
123
+ id: "msg-1",
124
+ mailboxId: "mbx-1",
125
+ fromName: "Alex Rivera",
126
+ subject: "Re: Q3 planning notes",
127
+ timeLabel: "Jan 17",
128
+ matched: "subject",
129
+ },
130
+ {
131
+ id: "msg-2",
132
+ mailboxId: "mbx-1",
133
+ fromName: "Billing",
134
+ subject: "Your invoice is ready",
135
+ timeLabel: "Yesterday",
136
+ matched: "body",
137
+ },
138
+ {
139
+ id: "msg-3",
140
+ mailboxId: "mbx-2",
141
+ fromName: "",
142
+ subject: "(No subject)",
143
+ timeLabel: "Dec 4, 2024",
144
+ matched: "sender",
145
+ },
146
+ ],
147
+ },
148
+ },
149
+ };