@remit/ui 0.0.13 → 0.0.14

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { isVirtualFolderRole, provenanceFolderLabel } from "./folder-role.js";
4
+
5
+ describe("provenanceFolderLabel", () => {
6
+ it("names an appointed role by its canonical label", () => {
7
+ assert.equal(provenanceFolderLabel({ role: "archive" }), "Archive");
8
+ assert.equal(provenanceFolderLabel({ role: "sent" }), "Sent");
9
+ });
10
+
11
+ it("reads a junk appointment as Spam whatever the server calls it", () => {
12
+ assert.equal(
13
+ provenanceFolderLabel({ role: "junk", providerPath: "Bulk Mail" }),
14
+ "Spam",
15
+ );
16
+ });
17
+
18
+ it("falls back to the leaf of a folder nobody appointed", () => {
19
+ assert.equal(
20
+ provenanceFolderLabel({ providerPath: "Projects/Bookkeeping" }),
21
+ "Bookkeeping",
22
+ );
23
+ });
24
+
25
+ it("refuses to label a view rather than a place", () => {
26
+ assert.equal(provenanceFolderLabel({ role: "all" }), undefined);
27
+ assert.equal(provenanceFolderLabel({ role: "flagged" }), undefined);
28
+ });
29
+
30
+ it("refuses to label Gmail's own reserved namespace", () => {
31
+ assert.equal(
32
+ provenanceFolderLabel({ providerPath: "[Gmail]/All Mail" }),
33
+ undefined,
34
+ );
35
+ assert.equal(
36
+ provenanceFolderLabel({ providerPath: "[Gmail]/Starred" }),
37
+ undefined,
38
+ );
39
+ });
40
+
41
+ it("refuses the googlemail.com spelling of the same namespace", () => {
42
+ assert.equal(
43
+ provenanceFolderLabel({ providerPath: "[Google Mail]/All Mail" }),
44
+ undefined,
45
+ );
46
+ assert.equal(
47
+ provenanceFolderLabel({ providerPath: "[Google Mail]/Starred" }),
48
+ undefined,
49
+ );
50
+ });
51
+
52
+ it("labels a user folder that merely mentions Gmail", () => {
53
+ assert.equal(provenanceFolderLabel({ providerPath: "Gmail" }), "Gmail");
54
+ });
55
+
56
+ it("has nothing to say about a folder it knows nothing about", () => {
57
+ assert.equal(provenanceFolderLabel({}), undefined);
58
+ });
59
+ });
60
+
61
+ describe("isVirtualFolderRole", () => {
62
+ it("counts All Mail and Starred as views", () => {
63
+ assert.equal(isVirtualFolderRole("all"), true);
64
+ assert.equal(isVirtualFolderRole("flagged"), true);
65
+ });
66
+
67
+ it("counts real folders as places", () => {
68
+ assert.equal(isVirtualFolderRole("inbox"), false);
69
+ assert.equal(isVirtualFolderRole("junk"), false);
70
+ assert.equal(isVirtualFolderRole("trash"), false);
71
+ });
72
+ });
@@ -54,6 +54,65 @@ export function providerLeaf(providerPath: string): string {
54
54
  return parts[parts.length - 1] || providerPath;
55
55
  }
56
56
 
57
+ /* ------------------------------------------------------------------ */
58
+ /* Provenance: where a search result actually lives */
59
+ /* ------------------------------------------------------------------ */
60
+
61
+ /**
62
+ * The folder a search result was read from. `role` is the account's IMAP
63
+ * special-use appointment (`junk` is `\Junk`); accounts that expose a folder
64
+ * nobody appointed carry only a `providerPath`.
65
+ */
66
+ export interface ResultFolder {
67
+ role?: FolderRole;
68
+ /** Provider path as the server spells it, e.g. `Projects/Bookkeeping`. */
69
+ providerPath?: string;
70
+ }
71
+
72
+ /**
73
+ * Roles that name a view rather than a place a message is filed. A message in
74
+ * All Mail or Starred is also somewhere real, so labelling a result with one of
75
+ * these says nothing about where it came from.
76
+ */
77
+ const VIRTUAL_ROLES: ReadonlySet<FolderRole> = new Set(["all", "flagged"]);
78
+
79
+ export function isVirtualFolderRole(role: FolderRole): boolean {
80
+ return VIRTUAL_ROLES.has(role);
81
+ }
82
+
83
+ /**
84
+ * Gmail exposes its views as ordinary folders under a reserved namespace. An
85
+ * account that appointed no role to them leaves the path as the only signal, so
86
+ * the namespace is matched by name — the one place a name is the honest test,
87
+ * because it is the provider's own reserved prefix and not a user's folder.
88
+ *
89
+ * Accounts provisioned under googlemail.com get the same namespace spelled
90
+ * `[Google Mail]`, so both forms count. A user folder plainly called `Gmail`
91
+ * does not — the brackets are what make the prefix reserved.
92
+ */
93
+ const GMAIL_NAMESPACES: ReadonlySet<string> = new Set([
94
+ "[Gmail]",
95
+ "[Google Mail]",
96
+ ]);
97
+
98
+ /**
99
+ * Label for the folder a result came from, or `undefined` when that folder is a
100
+ * view rather than a place — in which case no label is better than a misleading
101
+ * one. An appointed role wins over the provider's spelling, so a folder the
102
+ * account calls `Junk` still reads as "Spam".
103
+ */
104
+ export function provenanceFolderLabel(
105
+ folder: ResultFolder,
106
+ ): string | undefined {
107
+ if (folder.role) {
108
+ if (isVirtualFolderRole(folder.role)) return undefined;
109
+ return canonicalRoleLabel(folder.role);
110
+ }
111
+ if (!folder.providerPath) return undefined;
112
+ if (GMAIL_NAMESPACES.has(folder.providerPath.split("/")[0])) return undefined;
113
+ return providerLeaf(folder.providerPath);
114
+ }
115
+
57
116
  export function roleIcon(role: FolderRole): ReactNode {
58
117
  if (role === "inbox") return <Inbox className="size-4" />;
59
118
  if (role === "drafts") return <FileText className="size-4" />;
@@ -0,0 +1,78 @@
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 { MobileSearchView } from "./mobile-search-view.js";
6
+ import type { SearchResult } from "./search-result-row.js";
7
+
8
+ const noop = () => {};
9
+
10
+ const archived: SearchResult = {
11
+ id: "a1",
12
+ sender: "Mollie",
13
+ subject: "Invoice 2026-02",
14
+ snippet: "Payment already settled.",
15
+ date: "Feb 24",
16
+ folder: { role: "archive" },
17
+ };
18
+
19
+ const spam: SearchResult = {
20
+ id: "s1",
21
+ sender: "billing@unknown-vendor.test",
22
+ subject: "URGENT invoice attached",
23
+ snippet: "Wire the amount below.",
24
+ date: "Feb 11",
25
+ folder: { role: "junk" },
26
+ };
27
+
28
+ const sections = [
29
+ { id: "top", label: "Top matches", results: [archived, spam] },
30
+ ];
31
+
32
+ const base = {
33
+ value: "invoice",
34
+ onChange: noop,
35
+ onClear: noop,
36
+ onCancel: noop,
37
+ sections,
38
+ };
39
+
40
+ describe("MobileSearchView search scope", () => {
41
+ it("offers held-out spam on the phone tier too", () => {
42
+ const html = renderToString(
43
+ createElement(MobileSearchView, {
44
+ ...base,
45
+ scope: { kind: "global" as const },
46
+ onScopeToSpam: noop,
47
+ }),
48
+ );
49
+ assert.doesNotMatch(html, /unknown-vendor/);
50
+ assert.match(html, /result from Spam/);
51
+ assert.match(html, /Archive/);
52
+ });
53
+
54
+ it("shows neither spam nor provenance labels when scoped", () => {
55
+ const html = renderToString(
56
+ createElement(MobileSearchView, {
57
+ ...base,
58
+ scope: { kind: "folder" as const, role: "inbox" as const },
59
+ onScopeToSpam: noop,
60
+ }),
61
+ );
62
+ assert.doesNotMatch(html, /unknown-vendor/);
63
+ assert.doesNotMatch(html, /from Spam/);
64
+ assert.doesNotMatch(html, /Archive/);
65
+ });
66
+
67
+ it("passes a caller-supplied spam total through", () => {
68
+ const html = renderToString(
69
+ createElement(MobileSearchView, {
70
+ ...base,
71
+ scope: { kind: "global" as const },
72
+ spamMatchCount: 42,
73
+ onScopeToSpam: noop,
74
+ }),
75
+ );
76
+ assert.match(html, />42</);
77
+ });
78
+ });
@@ -8,7 +8,7 @@ import {
8
8
  import { MobileSearchView } from "./mobile-search-view.js";
9
9
  import type { SearchChip } from "./search-chip-input.js";
10
10
  import type { SearchResult } from "./search-result-row.js";
11
- import type { SearchResultSection } from "./search-results.js";
11
+ import type { SearchResultSection, SearchScope } from "./search-results.js";
12
12
 
13
13
  const phoneFrame: Decorator = (Story) => (
14
14
  <div
@@ -97,6 +97,56 @@ const emptySections: SearchResultSection[] = [
97
97
  { id: "related", label: "Related", results: [] },
98
98
  ];
99
99
 
100
+ /** Matches spread across ordinary folders, each carrying where it was read from. */
101
+ const crossFolderMatches: SearchResult[] = [
102
+ { ...topMatches[0], folder: { role: "inbox" } },
103
+ { ...topMatches[1], folder: { role: "inbox" } },
104
+ {
105
+ id: "x1",
106
+ sender: "Mollie",
107
+ subject: "Invoice 2026-02 — archived",
108
+ snippet: "Filed last month; payment already settled.",
109
+ date: "Feb 24",
110
+ folder: { role: "archive" },
111
+ },
112
+ {
113
+ id: "x2",
114
+ sender: "Accountant",
115
+ subject: "Invoices for the quarter",
116
+ snippet: "The quarterly set, filed with the rest of the bookkeeping.",
117
+ date: "Jan 30",
118
+ folder: { providerPath: "Projects/Bookkeeping" },
119
+ },
120
+ ];
121
+
122
+ /** Matches in the account's `\Junk` folder. */
123
+ const spamMatches: SearchResult[] = [
124
+ {
125
+ id: "s1",
126
+ sender: "billing@unknown-vendor.test",
127
+ subject: "URGENT invoice attached",
128
+ snippet: "Wire the amount below within 24 hours to avoid suspension.",
129
+ date: "Feb 11",
130
+ folder: { role: "junk" },
131
+ },
132
+ {
133
+ id: "s2",
134
+ sender: "invoices@pay-now.test",
135
+ subject: "Outstanding invoice — final notice",
136
+ snippet: "Your account is overdue. Settle immediately.",
137
+ date: "Feb 4",
138
+ folder: { role: "junk" },
139
+ },
140
+ ];
141
+
142
+ const acrossFoldersSections: SearchResultSection[] = [
143
+ {
144
+ id: "top",
145
+ label: "Top matches",
146
+ results: [...crossFolderMatches, ...spamMatches],
147
+ },
148
+ ];
149
+
100
150
  type Preset = "brief" | "inbox";
101
151
 
102
152
  function Harness({
@@ -105,12 +155,18 @@ function Harness({
105
155
  loading,
106
156
  sections,
107
157
  preset,
158
+ scope,
159
+ spamMatchCount,
160
+ onScopeToSpam,
108
161
  }: {
109
162
  initialValue?: string;
110
163
  initialChips?: SearchChip[];
111
164
  loading?: boolean;
112
165
  sections?: SearchResultSection[];
113
166
  preset: Preset;
167
+ scope?: SearchScope;
168
+ spamMatchCount?: number;
169
+ onScopeToSpam?: () => void;
114
170
  }) {
115
171
  const [value, setValue] = useState(initialValue);
116
172
  const [chips, setChips] = useState<SearchChip[]>(initialChips);
@@ -183,6 +239,9 @@ function Harness({
183
239
  sections={sections}
184
240
  loading={loading}
185
241
  onSelectResult={setOpened}
242
+ scope={scope}
243
+ spamMatchCount={spamMatchCount}
244
+ onScopeToSpam={onScopeToSpam}
186
245
  />
187
246
  );
188
247
  }
@@ -257,14 +316,70 @@ export const RelatedSelectable: Story = {
257
316
  * top bar uses, inside the full-screen takeover's own chrome. The chip is
258
317
  * removable in place — backspace at the start of the text reaches it just as it
259
318
  * does on desktop.
319
+ *
320
+ * The chip and the scope say the same thing, which is the point: an `in:spam`
321
+ * chip is what a Spam-scoped search looks like in the bar.
260
322
  */
261
323
  export const ScopedByChip: Story = {
262
324
  render: () => (
263
325
  <Harness
264
326
  initialValue="invoice"
265
327
  initialChips={[{ id: "in:spam", label: "in:spam" }]}
266
- sections={resultSections}
328
+ sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
329
+ scope={{ kind: "folder", role: "junk" }}
330
+ preset="inbox"
331
+ />
332
+ ),
333
+ };
334
+
335
+ /**
336
+ * Global search on the phone, holding spam out and offering it above the
337
+ * results — the same treatment the desktop list pane gives it, because both
338
+ * tiers render the one `SearchResults` body. Rows name the folder they came
339
+ * from; the two spam matches in the same data are not among them.
340
+ */
341
+ export const GlobalAcrossFolders: Story = {
342
+ render: () => (
343
+ <Harness
344
+ initialValue="invoice"
345
+ sections={acrossFoldersSections}
346
+ scope={{ kind: "global" }}
347
+ onScopeToSpam={() => {}}
348
+ preset="brief"
349
+ />
350
+ ),
351
+ };
352
+
353
+ /**
354
+ * The same rows scoped to the inbox. No spam, no count, no offer, and no
355
+ * provenance labels — the chip in the bar already says where the search is
356
+ * looking.
357
+ */
358
+ export const ScopedToInbox: Story = {
359
+ render: () => (
360
+ <Harness
361
+ initialValue="invoice"
362
+ initialChips={[{ id: "in:inbox", label: "in:inbox" }]}
363
+ sections={acrossFoldersSections}
364
+ scope={{ kind: "folder", role: "inbox" }}
365
+ onScopeToSpam={() => {}}
267
366
  preset="inbox"
268
367
  />
269
368
  ),
270
369
  };
370
+
371
+ /**
372
+ * A global phone search whose only matches are in Spam: the offer stands above
373
+ * the empty state rather than leaving the search looking fruitless.
374
+ */
375
+ export const GlobalOnlySpamMatches: Story = {
376
+ render: () => (
377
+ <Harness
378
+ initialValue="invoice"
379
+ sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
380
+ scope={{ kind: "global" }}
381
+ onScopeToSpam={() => {}}
382
+ preset="brief"
383
+ />
384
+ ),
385
+ };
@@ -4,7 +4,11 @@ import { FilterSheet, type FilterSheetProps } from "./filter-sheet.js";
4
4
  import { SearchBar } from "./search-bar.js";
5
5
  import type { SearchChip } from "./search-chip-input.js";
6
6
  import type { SearchResult } from "./search-result-row.js";
7
- import { type SearchResultSection, SearchResults } from "./search-results.js";
7
+ import {
8
+ type SearchResultSection,
9
+ SearchResults,
10
+ type SearchScope,
11
+ } from "./search-results.js";
8
12
 
9
13
  export interface MobileSearchViewProps {
10
14
  value: string;
@@ -40,6 +44,12 @@ export interface MobileSearchViewProps {
40
44
  */
41
45
  chips?: readonly SearchChip[];
42
46
  onRemoveChip?: (id: string) => void;
47
+ /** What the search covers; see `SearchResultsProps`. Defaults to global. */
48
+ scope?: SearchScope;
49
+ /** Total spam matches found; see `SearchResultsProps`. */
50
+ spamMatchCount?: number;
51
+ /** Scope the search to Spam; see `SearchResultsProps`. */
52
+ onScopeToSpam?: () => void;
43
53
  }
44
54
 
45
55
  /**
@@ -52,6 +62,9 @@ export interface MobileSearchViewProps {
52
62
  * the inboxes use) so search carries identical filters; pass no `filter` to drop
53
63
  * the chrome. Desktop reuses the same `SearchResults` body in the list pane.
54
64
  * Presentational and prop-driven.
65
+ *
66
+ * Search scope passes straight through, so the phone tier holds spam out,
67
+ * offers it and labels provenance on exactly the same terms as desktop.
55
68
  */
56
69
  export function MobileSearchView({
57
70
  value,
@@ -67,6 +80,9 @@ export function MobileSearchView({
67
80
  tokens,
68
81
  chips,
69
82
  onRemoveChip,
83
+ scope,
84
+ spamMatchCount,
85
+ onScopeToSpam,
70
86
  }: MobileSearchViewProps) {
71
87
  const body = (
72
88
  <SearchResults
@@ -77,6 +93,9 @@ export function MobileSearchView({
77
93
  loading={loading}
78
94
  onSelectResult={onSelectResult}
79
95
  tokens={tokens}
96
+ scope={scope}
97
+ spamMatchCount={spamMatchCount}
98
+ onScopeToSpam={onScopeToSpam}
80
99
  />
81
100
  );
82
101
 
@@ -2,6 +2,7 @@ import { Flag } from "lucide-react";
2
2
  import type { ReactNode } from "react";
3
3
  import { cn } from "../lib/cn.js";
4
4
  import { Badge } from "./badge.js";
5
+ import { provenanceFolderLabel, type ResultFolder } from "./folder-role.js";
5
6
 
6
7
  export type SearchResultTone =
7
8
  | "neutral"
@@ -27,6 +28,12 @@ export interface SearchResult {
27
28
  threadId?: string;
28
29
  /** The mailbox the result lives in; paired with {@link threadId} to open it. */
29
30
  mailboxId?: string;
31
+ /**
32
+ * The folder this row was read from. A search that reaches every folder
33
+ * returns rows from all over, so the row says where it came from; see
34
+ * {@link provenanceFolderLabel} for which folders can be named.
35
+ */
36
+ folder?: ResultFolder;
30
37
  /**
31
38
  * Why a semantic ("Related") hit matched — a plain-language label derived
32
39
  * from `matchedChunkType` (e.g. "body", "subject", "attachment"), so the user
@@ -43,6 +50,12 @@ export interface SearchResultRowProps {
43
50
  onClick?: () => void;
44
51
  /** When given, literal (case-insensitive) matches are bolded in subject/snippet. */
45
52
  query?: string;
53
+ /**
54
+ * Show the folder the row came from. Defaults to true. A search confined to
55
+ * one folder turns it off — every row would carry the same label, which is
56
+ * noise rather than provenance.
57
+ */
58
+ showFolder?: boolean;
46
59
  }
47
60
 
48
61
  function highlight(text: string, query?: string): ReactNode {
@@ -79,7 +92,12 @@ export function SearchResultRow({
79
92
  result,
80
93
  onClick,
81
94
  query,
95
+ showFolder = true,
82
96
  }: SearchResultRowProps) {
97
+ const folderLabel =
98
+ showFolder && result.folder
99
+ ? provenanceFolderLabel(result.folder)
100
+ : undefined;
83
101
  return (
84
102
  <button
85
103
  type="button"
@@ -118,6 +136,11 @@ export function SearchResultRow({
118
136
  <span className="min-w-0 flex-1 truncate text-xs text-fg-subtle">
119
137
  {highlight(result.snippet, query)}
120
138
  </span>
139
+ {folderLabel && (
140
+ <Badge tone="neutral" className="shrink-0">
141
+ {folderLabel}
142
+ </Badge>
143
+ )}
121
144
  {result.category && (
122
145
  <Badge tone={result.category.tone ?? "neutral"} className="shrink-0">
123
146
  {result.category.label}
@@ -87,3 +87,184 @@ describe("SearchResults", () => {
87
87
  assert.doesNotMatch(html, /Remove filter/);
88
88
  });
89
89
  });
90
+
91
+ const spamResult: SearchResult = {
92
+ id: "s1",
93
+ sender: "billing@unknown-vendor.test",
94
+ subject: "URGENT invoice attached",
95
+ snippet: "Wire the amount below.",
96
+ date: "Feb 11",
97
+ folder: { role: "junk" },
98
+ };
99
+
100
+ const archivedResult: SearchResult = {
101
+ ...result,
102
+ id: "a1",
103
+ sender: "Mollie",
104
+ folder: { role: "archive" },
105
+ };
106
+
107
+ const mixed: SearchResultSection[] = [
108
+ { id: "results", label: "Results", results: [archivedResult, spamResult] },
109
+ ];
110
+
111
+ describe("SearchResults spam handling", () => {
112
+ it("holds spam out of a global search and offers it as a count", () => {
113
+ const html = renderToString(
114
+ createElement(SearchResults, {
115
+ value: "invoice",
116
+ sections: mixed,
117
+ scope: { kind: "global" },
118
+ onScopeToSpam: noop,
119
+ }),
120
+ );
121
+ assert.doesNotMatch(html, /unknown-vendor/);
122
+ assert.match(html, /result from Spam/);
123
+ assert.match(html, /Mollie/);
124
+ });
125
+
126
+ it("makes no offer when a global search found nothing in spam", () => {
127
+ const html = renderToString(
128
+ createElement(SearchResults, {
129
+ value: "invoice",
130
+ sections: [
131
+ { id: "results", label: "Results", results: [archivedResult] },
132
+ ],
133
+ scope: { kind: "global" },
134
+ onScopeToSpam: noop,
135
+ }),
136
+ );
137
+ assert.doesNotMatch(html, /from Spam/);
138
+ });
139
+
140
+ it("offers spam above the empty state when every match is spam", () => {
141
+ const html = renderToString(
142
+ createElement(SearchResults, {
143
+ value: "invoice",
144
+ sections: [{ id: "results", label: "Results", results: [spamResult] }],
145
+ scope: { kind: "global" },
146
+ onScopeToSpam: noop,
147
+ }),
148
+ );
149
+ assert.match(html, /No matches for/);
150
+ assert.match(html, /result from Spam/);
151
+ });
152
+
153
+ it("shows neither spam rows nor an offer when scoped elsewhere", () => {
154
+ const html = renderToString(
155
+ createElement(SearchResults, {
156
+ value: "invoice",
157
+ sections: mixed,
158
+ scope: { kind: "folder", role: "inbox" },
159
+ onScopeToSpam: noop,
160
+ }),
161
+ );
162
+ assert.doesNotMatch(html, /unknown-vendor/);
163
+ assert.doesNotMatch(html, /from Spam/);
164
+ assert.match(html, /Mollie/);
165
+ });
166
+
167
+ it("renders spam rows normally and makes no offer when scoped to spam", () => {
168
+ const html = renderToString(
169
+ createElement(SearchResults, {
170
+ value: "invoice",
171
+ sections: mixed,
172
+ scope: { kind: "folder", role: "junk" },
173
+ onScopeToSpam: noop,
174
+ }),
175
+ );
176
+ assert.match(html, /unknown-vendor/);
177
+ assert.doesNotMatch(html, /from Spam/);
178
+ });
179
+
180
+ it("makes no offer without a way to scope to spam", () => {
181
+ const html = renderToString(
182
+ createElement(SearchResults, {
183
+ value: "invoice",
184
+ sections: mixed,
185
+ scope: { kind: "global" },
186
+ }),
187
+ );
188
+ assert.doesNotMatch(html, /from Spam/);
189
+ });
190
+
191
+ it("prefers a caller-supplied total over the rows held out of this page", () => {
192
+ const html = renderToString(
193
+ createElement(SearchResults, {
194
+ value: "invoice",
195
+ sections: mixed,
196
+ scope: { kind: "global" },
197
+ spamMatchCount: 42,
198
+ onScopeToSpam: noop,
199
+ }),
200
+ );
201
+ assert.match(html, />42</);
202
+ });
203
+ });
204
+
205
+ describe("SearchResults provenance labels", () => {
206
+ it("names the folder each row came from in a global search", () => {
207
+ const html = renderToString(
208
+ createElement(SearchResults, {
209
+ value: "invoice",
210
+ sections: [
211
+ {
212
+ id: "results",
213
+ label: "Results",
214
+ results: [
215
+ archivedResult,
216
+ {
217
+ ...result,
218
+ id: "c1",
219
+ folder: { providerPath: "Projects/Books" },
220
+ },
221
+ ],
222
+ },
223
+ ],
224
+ scope: { kind: "global" },
225
+ }),
226
+ );
227
+ assert.match(html, /Archive/);
228
+ assert.match(html, /Books/);
229
+ });
230
+
231
+ it("drops the labels when the search is scoped to one folder", () => {
232
+ const html = renderToString(
233
+ createElement(SearchResults, {
234
+ value: "invoice",
235
+ sections: [
236
+ { id: "results", label: "Results", results: [archivedResult] },
237
+ ],
238
+ scope: { kind: "folder", role: "archive" },
239
+ }),
240
+ );
241
+ assert.doesNotMatch(html, /Archive/);
242
+ });
243
+
244
+ it("leaves a row from a view rather than a folder unlabelled", () => {
245
+ const html = renderToString(
246
+ createElement(SearchResults, {
247
+ value: "invoice",
248
+ sections: [
249
+ {
250
+ id: "results",
251
+ label: "Results",
252
+ results: [
253
+ { ...result, id: "v1", folder: { role: "all" } },
254
+ { ...result, id: "v2", folder: { role: "flagged" } },
255
+ {
256
+ ...result,
257
+ id: "v3",
258
+ folder: { providerPath: "[Gmail]/Important" },
259
+ },
260
+ ],
261
+ },
262
+ ],
263
+ scope: { kind: "global" },
264
+ }),
265
+ );
266
+ assert.doesNotMatch(html, /All Mail/);
267
+ assert.doesNotMatch(html, /Starred/);
268
+ assert.doesNotMatch(html, /Important/);
269
+ });
270
+ });
@@ -73,43 +73,71 @@ const resultSections: SearchResultSection[] = [
73
73
  ];
74
74
 
75
75
  /**
76
- * Matches from outside the inbox — Archive, Sent, Spam and a custom folder.
77
- * These are the rows an unscoped search returns that an INBOX-only one could
78
- * not, so they only ever appear under the brief's sections.
76
+ * Matches from outside the inbox — Archive, Sent and a custom folder — each
77
+ * carrying the folder it was read from. These are the rows a search reaching
78
+ * every folder returns that an INBOX-only one could not.
79
79
  */
80
80
  const crossFolderMatches: SearchResult[] = [
81
81
  {
82
82
  id: "x1",
83
83
  sender: "Mollie",
84
84
  subject: "Invoice 2026-02 — archived",
85
- snippet: "Filed to Archive last month; payment already settled.",
85
+ snippet: "Filed last month; payment already settled.",
86
86
  date: "Feb 24",
87
+ folder: { role: "archive" },
87
88
  category: { label: "Receipt", tone: "positive" },
88
89
  },
89
90
  {
90
91
  id: "x2",
91
92
  sender: "me",
92
93
  subject: "Re: invoice query",
93
- snippet: "Sent — attaching the invoice you asked for.",
94
+ snippet: "Attaching the invoice you asked for.",
94
95
  date: "Feb 18",
96
+ folder: { role: "sent" },
95
97
  },
96
98
  {
97
- id: "x3",
99
+ id: "x4",
100
+ sender: "Accountant",
101
+ subject: "Invoices for the quarter",
102
+ snippet: "The quarterly set, filed with the rest of the bookkeeping.",
103
+ date: "Jan 30",
104
+ folder: { providerPath: "Projects/Bookkeeping" },
105
+ },
106
+ ];
107
+
108
+ /**
109
+ * Matches that live in the account's `\Junk` folder. A global search holds
110
+ * these out of the sections entirely and offers them as a count instead.
111
+ */
112
+ const spamMatches: SearchResult[] = [
113
+ {
114
+ id: "s1",
98
115
  sender: "billing@unknown-vendor.test",
99
116
  subject: "URGENT invoice attached",
100
- snippet: "Marked as spam, but it is the invoice the user is looking for.",
117
+ snippet: "Wire the amount below within 24 hours to avoid suspension.",
101
118
  date: "Feb 11",
102
- category: { label: "Spam", tone: "warning" },
119
+ folder: { role: "junk" },
103
120
  },
104
121
  {
105
- id: "x4",
106
- sender: "Accountant",
107
- subject: "Invoices for the quarter",
108
- snippet: "Filed under Projects/Bookkeeping.",
109
- date: "Jan 30",
122
+ id: "s2",
123
+ sender: "invoices@pay-now.test",
124
+ subject: "Outstanding invoice final notice",
125
+ snippet: "Your account is overdue. Settle immediately.",
126
+ date: "Feb 4",
127
+ folder: { role: "junk" },
110
128
  },
111
129
  ];
112
130
 
131
+ /** The literal section a global search returns: inbox rows plus everywhere else. */
132
+ const globalTopMatches: SearchResult[] = [
133
+ ...topMatches.map((result) => ({
134
+ ...result,
135
+ folder: { role: "inbox" as const },
136
+ })),
137
+ ...crossFolderMatches,
138
+ ...spamMatches,
139
+ ];
140
+
113
141
  const emptySections: SearchResultSection[] = [
114
142
  { id: "top", label: "Top matches", results: [] },
115
143
  ];
@@ -151,23 +179,45 @@ export const Idle: Story = {
151
179
 
152
180
  /**
153
181
  * The daily brief's unscoped search: no scope chip in the bar, and the literal
154
- * section carries matches from every folder — Archive, Sent, Spam and custom
155
- * folders alongside the inbox. Before the listing behind it took a search mode
156
- * it could only ever return inbox mail, so this section was silently narrower
157
- * than the bar promised.
182
+ * section carries matches from every folder — Archive, Sent and custom folders
183
+ * alongside the inbox. Each row says which folder it came from, because with
184
+ * nothing scoping the search the folder is the only thing placing the result.
158
185
  *
159
- * The rows themselves do not say which folder they came from; the sections are
160
- * ordered newest first regardless of where each message is filed.
186
+ * The two spam matches in the same data are not in this list. They are held out
187
+ * and offered above it as a count.
161
188
  */
162
- export const UnscopedAcrossFolders: Story = {
189
+ export const GlobalAcrossFolders: Story = {
163
190
  render: () => (
164
191
  <Harness
165
192
  value="invoice"
193
+ scope={{ kind: "global" }}
194
+ onScopeToSpam={() => {}}
195
+ sections={[
196
+ { id: "top", label: "Top matches", results: globalTopMatches },
197
+ { id: "related", label: "Related", results: related },
198
+ ]}
199
+ />
200
+ ),
201
+ };
202
+
203
+ /**
204
+ * The same global search over an account whose Spam folder holds nothing
205
+ * matching. No spam rows to hold out, so no offer — the offer only ever appears
206
+ * because there is something behind it.
207
+ */
208
+ export const GlobalWithoutSpamMatches: Story = {
209
+ render: () => (
210
+ <Harness
211
+ value="invoice"
212
+ scope={{ kind: "global" }}
213
+ onScopeToSpam={() => {}}
166
214
  sections={[
167
215
  {
168
216
  id: "top",
169
217
  label: "Top matches",
170
- results: [...topMatches, ...crossFolderMatches],
218
+ results: globalTopMatches.filter(
219
+ (result) => result.folder?.role !== "junk",
220
+ ),
171
221
  },
172
222
  { id: "related", label: "Related", results: related },
173
223
  ]}
@@ -176,18 +226,105 @@ export const UnscopedAcrossFolders: Story = {
176
226
  };
177
227
 
178
228
  /**
179
- * A scoped view (a mailbox route, its `in:` chip in the bar). Both sections are
180
- * scoped to that folder and take the kit's default labels the semantic
181
- * section used to run unscoped here under an "Everywhere" heading, which
182
- * contradicted the chip the same bar was showing.
229
+ * An account with no junk folder at all. Nothing is appointed `\Junk`, so no
230
+ * row can be spam and the component behaves exactly as it does when Spam is
231
+ * simply empty there is no separate case to handle.
232
+ */
233
+ export const GlobalAccountWithoutSpamFolder: Story = {
234
+ render: () => (
235
+ <Harness
236
+ value="invoice"
237
+ scope={{ kind: "global" }}
238
+ onScopeToSpam={() => {}}
239
+ sections={[
240
+ { id: "top", label: "Top matches", results: crossFolderMatches },
241
+ ]}
242
+ />
243
+ ),
244
+ };
245
+
246
+ /**
247
+ * A global search whose only matches are in Spam. The sections are empty, so
248
+ * the empty state stands — with the offer above it, which is the whole reason
249
+ * the user is not left thinking the search found nothing.
250
+ */
251
+ export const GlobalOnlySpamMatches: Story = {
252
+ render: () => (
253
+ <Harness
254
+ value="invoice"
255
+ scope={{ kind: "global" }}
256
+ onScopeToSpam={() => {}}
257
+ sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
258
+ />
259
+ ),
260
+ };
261
+
262
+ /**
263
+ * Scoped to the inbox (its `in:inbox` chip in the bar), given the very same
264
+ * rows as the global story — spam matches included. Nothing about Spam appears:
265
+ * no rows, no count, no offer. A scoped search shows its own scope and no more,
266
+ * and that asymmetry with the global view is deliberate.
267
+ *
268
+ * The rows also drop their folder labels here. Every row is in the scoped
269
+ * folder, so naming it on each one repeats the chip.
270
+ */
271
+ export const ScopedToInbox: Story = {
272
+ render: () => (
273
+ <Harness
274
+ value="invoice"
275
+ scope={{ kind: "folder", role: "inbox" }}
276
+ onScopeToSpam={() => {}}
277
+ sections={[
278
+ { id: "top", label: "Top matches", results: globalTopMatches },
279
+ { id: "related", label: "Related", results: related },
280
+ ]}
281
+ />
282
+ ),
283
+ };
284
+
285
+ /**
286
+ * Scoped to Spam — where taking the offer lands. Ordinary rows, rendered
287
+ * normally, and no offer, because the user is already here. This is the same
288
+ * scoped search reached by navigating to Spam with the query carried over; the
289
+ * offer is a shortcut into it, not a mode of its own.
290
+ */
291
+ export const ScopedToSpam: Story = {
292
+ render: () => (
293
+ <Harness
294
+ value="invoice"
295
+ scope={{ kind: "folder", role: "junk" }}
296
+ onScopeToSpam={() => {}}
297
+ sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
298
+ />
299
+ ),
300
+ };
301
+
302
+ /**
303
+ * A folder a search result can be in but never labelled with. All Mail and
304
+ * Starred are views over mail filed elsewhere, and Gmail exposes them as
305
+ * ordinary folders, so a row read from one carries no provenance label rather
306
+ * than a misleading one. The other rows keep theirs.
183
307
  */
184
- export const ScopedToOneFolder: Story = {
308
+ export const VirtualFoldersGoUnlabelled: Story = {
185
309
  render: () => (
186
310
  <Harness
187
311
  value="invoice"
312
+ scope={{ kind: "global" }}
188
313
  sections={[
189
- { id: "top", label: "Top matches", results: topMatches.slice(0, 2) },
190
- { id: "related", label: "Related", results: related.slice(0, 1) },
314
+ {
315
+ id: "top",
316
+ label: "Top matches",
317
+ results: [
318
+ { ...topMatches[0], id: "v1", folder: { role: "all" } },
319
+ { ...topMatches[1], id: "v2", folder: { role: "flagged" } },
320
+ {
321
+ ...topMatches[2],
322
+ id: "v3",
323
+ folder: { providerPath: "[Gmail]/Important" },
324
+ },
325
+ ...crossFolderMatches.slice(0, 1),
326
+ ],
327
+ },
191
328
  ]}
192
329
  />
193
330
  ),
@@ -1,8 +1,10 @@
1
1
  import { ChevronDown, Clock } from "lucide-react";
2
2
  import { useState } from "react";
3
3
  import { cn } from "../lib/cn.js";
4
+ import type { FolderRole } from "./folder-role.js";
4
5
  import { type SearchResult, SearchResultRow } from "./search-result-row.js";
5
6
  import { SearchTokenChips } from "./search-token-chip.js";
7
+ import { SpamResultsOffer } from "./spam-results-offer.js";
6
8
 
7
9
  /** Rows shown before the "Show N more" expander kicks in. */
8
10
  const SECTION_ROW_CAP = 6;
@@ -15,6 +17,44 @@ export interface SearchResultSection {
15
17
  initialCollapsed?: boolean;
16
18
  }
17
19
 
20
+ /**
21
+ * What the search currently covers. `global` is the unscoped search the daily
22
+ * brief runs — every account, every folder, no chip in the bar. `folder` is a
23
+ * search the sidebar narrowed to one place, which the bar shows as a chip.
24
+ */
25
+ export type SearchScope =
26
+ | { kind: "global" }
27
+ | { kind: "folder"; role?: FolderRole };
28
+
29
+ const GLOBAL_SCOPE: SearchScope = { kind: "global" };
30
+
31
+ const isSpamScope = (scope: SearchScope): boolean =>
32
+ scope.kind === "folder" && scope.role === "junk";
33
+
34
+ const isSpamResult = (result: SearchResult): boolean =>
35
+ result.folder?.role === "junk";
36
+
37
+ /**
38
+ * Split spam matches out of the rows a search returned.
39
+ *
40
+ * Spam is identified by the account's `\Junk` special-use appointment, never by
41
+ * folder name, so an account that calls it `Junk`, `Bulk Mail` or nothing at
42
+ * all behaves the same, and an account with no junk folder simply never yields
43
+ * a spam row.
44
+ */
45
+ export function partitionSpamResults(results: SearchResult[]): {
46
+ kept: SearchResult[];
47
+ spam: SearchResult[];
48
+ } {
49
+ const kept: SearchResult[] = [];
50
+ const spam: SearchResult[] = [];
51
+ for (const result of results) {
52
+ if (isSpamResult(result)) spam.push(result);
53
+ else kept.push(result);
54
+ }
55
+ return { kept, spam };
56
+ }
57
+
18
58
  export interface SearchResultsProps {
19
59
  /** The current query — narrows what's shown and bolds literal matches. */
20
60
  value: string;
@@ -32,6 +72,19 @@ export interface SearchResultsProps {
32
72
  * query has no recognized tokens.
33
73
  */
34
74
  tokens?: { label: string; onRemove: () => void }[];
75
+ /** What the search covers. Defaults to the unscoped, global search. */
76
+ scope?: SearchScope;
77
+ /**
78
+ * Total spam matches the search found, for when the app knows more than the
79
+ * rows on this page. Defaults to the number of spam rows held out of
80
+ * {@link sections}.
81
+ */
82
+ spamMatchCount?: number;
83
+ /**
84
+ * Scope the search to Spam. Without it there is no offer, so an app that has
85
+ * nowhere to navigate simply never makes one.
86
+ */
87
+ onScopeToSpam?: () => void;
35
88
  }
36
89
 
37
90
  /**
@@ -44,10 +97,12 @@ function CollapsibleResultSection({
44
97
  section,
45
98
  query,
46
99
  onSelectResult,
100
+ showFolder,
47
101
  }: {
48
102
  section: SearchResultSection;
49
103
  query?: string;
50
104
  onSelectResult?: (result: SearchResult) => void;
105
+ showFolder?: boolean;
51
106
  }) {
52
107
  const [collapsed, setCollapsed] = useState(section.initialCollapsed ?? false);
53
108
  const [expanded, setExpanded] = useState(false);
@@ -87,6 +142,7 @@ function CollapsibleResultSection({
87
142
  key={result.id}
88
143
  result={result}
89
144
  query={query}
145
+ showFolder={showFolder}
90
146
  onClick={
91
147
  onSelectResult ? () => onSelectResult(result) : undefined
92
148
  }
@@ -115,6 +171,18 @@ function CollapsibleResultSection({
115
171
  * sections — one implementation so both tiers render identical rows. The caller
116
172
  * owns the surrounding chrome and scroll container (a `FilterSheet`, the
117
173
  * takeover header, or the list-pane body). Presentational and prop-driven.
174
+ *
175
+ * Spam is the one folder that does not inline, and it behaves differently by
176
+ * scope:
177
+ *
178
+ * - **Global** — spam rows are held out of the sections and offered instead, as
179
+ * a count with a way into a Spam-scoped search.
180
+ * - **Scoped to anything else** — spam rows are held out and nothing is offered.
181
+ * A scoped search shows its own scope and no more.
182
+ * - **Scoped to Spam** — ordinary rows, rendered normally, no offer.
183
+ *
184
+ * Provenance labels follow the same logic: only a global search names the
185
+ * folder each row came from, because a scoped one would repeat itself.
118
186
  */
119
187
  export function SearchResults({
120
188
  value,
@@ -124,11 +192,11 @@ export function SearchResults({
124
192
  loading,
125
193
  onSelectResult,
126
194
  tokens,
195
+ scope = GLOBAL_SCOPE,
196
+ spamMatchCount,
197
+ onScopeToSpam,
127
198
  }: SearchResultsProps) {
128
199
  const hasQuery = value.trim().length > 0;
129
- const hasResults = (sections ?? []).some(
130
- (section) => section.results.length > 0,
131
- );
132
200
 
133
201
  if (!hasQuery) {
134
202
  if (recentSearches && recentSearches.length > 0) {
@@ -181,10 +249,36 @@ export function SearchResults({
181
249
  );
182
250
  }
183
251
 
252
+ const spamScoped = isSpamScope(scope);
253
+ const isGlobal = scope.kind === "global";
254
+
255
+ const partitioned = (sections ?? []).map((section) => {
256
+ if (spamScoped) return { section, spam: [] as SearchResult[] };
257
+ const { kept, spam } = partitionSpamResults(section.results);
258
+ return { section: { ...section, results: kept }, spam };
259
+ });
260
+
261
+ const visibleSections = partitioned.map((entry) => entry.section);
262
+ const heldOutSpamCount = partitioned.reduce(
263
+ (total, entry) => total + entry.spam.length,
264
+ 0,
265
+ );
266
+ const spamCount = spamMatchCount ?? heldOutSpamCount;
267
+ const spamOffer = isGlobal &&
268
+ spamCount > 0 &&
269
+ onScopeToSpam !== undefined && (
270
+ <SpamResultsOffer count={spamCount} onScopeToSpam={onScopeToSpam} />
271
+ );
272
+
273
+ const hasResults = visibleSections.some(
274
+ (section) => section.results.length > 0,
275
+ );
276
+
184
277
  if (!hasResults) {
185
278
  return (
186
279
  <div className="flex flex-col">
187
280
  {chips}
281
+ {spamOffer}
188
282
  <div className="px-row-inset py-10 text-center">
189
283
  <p className="text-sm font-medium text-fg">
190
284
  No matches for &ldquo;{value}&rdquo;
@@ -200,13 +294,15 @@ export function SearchResults({
200
294
  return (
201
295
  <div className="flex flex-col">
202
296
  {chips}
203
- {sections
204
- ?.filter((section) => section.results.length > 0)
297
+ {spamOffer}
298
+ {visibleSections
299
+ .filter((section) => section.results.length > 0)
205
300
  .map((section) => (
206
301
  <CollapsibleResultSection
207
302
  key={section.id}
208
303
  section={section}
209
304
  query={value}
305
+ showFolder={isGlobal}
210
306
  onSelectResult={onSelectResult}
211
307
  />
212
308
  ))}
@@ -0,0 +1,26 @@
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 { SpamResultsOffer } from "./spam-results-offer.js";
6
+
7
+ const noop = () => {};
8
+
9
+ describe("SpamResultsOffer", () => {
10
+ it("states the count and offers a way into Spam", () => {
11
+ const html = renderToString(
12
+ createElement(SpamResultsOffer, { count: 3, onScopeToSpam: noop }),
13
+ );
14
+ assert.match(html, />3</);
15
+ assert.match(html, /results from Spam/);
16
+ assert.match(html, /View them/);
17
+ });
18
+
19
+ it("reads in the singular for one match", () => {
20
+ const html = renderToString(
21
+ createElement(SpamResultsOffer, { count: 1, onScopeToSpam: noop }),
22
+ );
23
+ assert.match(html, /result from Spam/);
24
+ assert.doesNotMatch(html, /results from Spam/);
25
+ });
26
+ });
@@ -0,0 +1,41 @@
1
+ import type { Decorator, Meta, StoryObj } from "@storybook/react";
2
+ import { SpamResultsOffer } from "./spam-results-offer.js";
3
+
4
+ /** Frames the offer at the width of the list pane it sits at the top of. */
5
+ const listPaneFrame: Decorator = (Story) => (
6
+ <div
7
+ className="overflow-hidden rounded-lg border border-line bg-canvas"
8
+ style={{ width: 360 }}
9
+ >
10
+ <Story />
11
+ </div>
12
+ );
13
+
14
+ const meta: Meta<typeof SpamResultsOffer> = {
15
+ title: "Components/SpamResultsOffer",
16
+ component: SpamResultsOffer,
17
+ parameters: { layout: "centered" },
18
+ decorators: [listPaneFrame],
19
+ };
20
+ export default meta;
21
+
22
+ type Story = StoryObj<typeof SpamResultsOffer>;
23
+
24
+ /**
25
+ * Spam matches a global search held out of its results. Deliberately quiet —
26
+ * an offer, not a warning — and the action scopes the search to Spam rather
27
+ * than opening a view of its own.
28
+ */
29
+ export const Default: Story = {
30
+ args: { count: 3, onScopeToSpam: () => {} },
31
+ };
32
+
33
+ /** One match reads in the singular. */
34
+ export const SingleResult: Story = {
35
+ args: { count: 1, onScopeToSpam: () => {} },
36
+ };
37
+
38
+ /** Large counts stay on one line; the figure is tabular so it does not jitter. */
39
+ export const ManyResults: Story = {
40
+ args: { count: 128, onScopeToSpam: () => {} },
41
+ };
@@ -0,0 +1,52 @@
1
+ import { Banner } from "./banner.js";
2
+ import { Button } from "./button.js";
3
+
4
+ export interface SpamResultsOfferProps {
5
+ /** How many matches the search found in Spam. */
6
+ count: number;
7
+ /**
8
+ * Scope the search to Spam. This is a shortcut to the state reached by
9
+ * navigating to Spam with the query carried over — the same scoped search,
10
+ * with the same `in:spam` chip — not a separate result mode.
11
+ */
12
+ onScopeToSpam: () => void;
13
+ }
14
+
15
+ const plural = (n: number): string => (n === 1 ? "result" : "results");
16
+
17
+ /**
18
+ * Spam matches held out of a global search, offered rather than mixed in. Spam
19
+ * is the one folder a search that reaches everywhere does not inline, because
20
+ * the whole point of the folder is that its contents are unwanted until asked
21
+ * for. Taking the offer scopes the search to Spam.
22
+ *
23
+ * Quiet on purpose: this is an offer, not a warning. Presentational — the
24
+ * caller owns what "scope to spam" does.
25
+ */
26
+ export function SpamResultsOffer({
27
+ count,
28
+ onScopeToSpam,
29
+ }: SpamResultsOfferProps) {
30
+ return (
31
+ <Banner
32
+ tone="info"
33
+ variant="soft"
34
+ className="items-center justify-between gap-3 rounded-none border-b border-line"
35
+ >
36
+ <div className="flex items-center justify-between gap-3">
37
+ <p className="min-w-0 text-xs text-fg-muted">
38
+ <span className="font-semibold text-fg tabular-nums">{count}</span>{" "}
39
+ {`${plural(count)} from Spam`}
40
+ </p>
41
+ <Button
42
+ variant="ghost"
43
+ size="sm"
44
+ onClick={onScopeToSpam}
45
+ className="shrink-0 text-accent"
46
+ >
47
+ View them
48
+ </Button>
49
+ </div>
50
+ </Banner>
51
+ );
52
+ }
package/src/index.ts CHANGED
@@ -121,7 +121,10 @@ export {
121
121
  export {
122
122
  canonicalRoleLabel,
123
123
  type FolderRole,
124
+ isVirtualFolderRole,
125
+ provenanceFolderLabel,
124
126
  providerLeaf,
127
+ type ResultFolder,
125
128
  roleIcon,
126
129
  } from "./components/folder-role.js";
127
130
  export {
@@ -304,9 +307,11 @@ export {
304
307
  type SearchResultTone,
305
308
  } from "./components/search-result-row.js";
306
309
  export {
310
+ partitionSpamResults,
307
311
  type SearchResultSection,
308
312
  SearchResults,
309
313
  type SearchResultsProps,
314
+ type SearchScope,
310
315
  } from "./components/search-results.js";
311
316
  export {
312
317
  SearchChipRow,
@@ -358,6 +363,10 @@ export {
358
363
  SlidePanel,
359
364
  type SlidePanelProps,
360
365
  } from "./components/slide-panel.js";
366
+ export {
367
+ SpamResultsOffer,
368
+ type SpamResultsOfferProps,
369
+ } from "./components/spam-results-offer.js";
361
370
  export {
362
371
  commitPeek,
363
372
  SwipeableRow,