@remit/ui 0.0.138 → 0.0.140

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.138",
3
+ "version": "0.0.140",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -55,7 +55,7 @@ export const FolderManager = ({
55
55
  delimiter={delimiter}
56
56
  labels={{
57
57
  treeAriaLabel: "Your folders",
58
- optionLabel: (label) => label,
58
+ optionLabel: (folder) => folder.label,
59
59
  ...labels,
60
60
  }}
61
61
  rowActions={(folder) => {
@@ -48,7 +48,11 @@ export function canonicalRoleLabel(role: FolderRole): string {
48
48
  return ROLE_LABEL[role];
49
49
  }
50
50
 
51
- /** Leaf segment of a provider path (`INBOX/Spam` → `Spam`). */
51
+ /**
52
+ * Leaf segment of a provider path (`INBOX/Spam` → `Spam`). Only for the two
53
+ * surfaces whose wire payload carries no `hierarchyDelimiter` — quarantine
54
+ * entries and search-result provenance. Everything else: `folderLeaf`.
55
+ */
52
56
  export function providerLeaf(providerPath: string): string {
53
57
  const parts = providerPath.split("/");
54
58
  return parts[parts.length - 1] || providerPath;
@@ -81,6 +81,20 @@ describe("FolderRow", () => {
81
81
  assert.ok(html.indexOf(">Delete<") > html.indexOf("</button>"));
82
82
  });
83
83
 
84
+ it("shows how much mail the folder holds, and leaves zero visible", () => {
85
+ assert.match(render({ messageCount: 512 }), />512</);
86
+ assert.match(render({ messageCount: 512 }), />msgs</);
87
+ assert.match(render({ messageCount: 1 }), />msg</);
88
+ assert.match(render({ messageCount: 0 }), />0</);
89
+ assert.doesNotMatch(render({}), /msgs/);
90
+ });
91
+
92
+ it("keeps the count out of the accessible name it was not folded into", () => {
93
+ const html = render({ messageCount: 512 });
94
+ assert.match(html, /aria-label="Move to Travel"/);
95
+ assert.match(html, /aria-hidden="true"[^>]*class="[^"]*text-2xs/);
96
+ });
97
+
84
98
  it("takes its place in a roving tab order", () => {
85
99
  assert.match(render({ tabIndex: 0 }), /tabindex="0"/);
86
100
  assert.match(render({ tabIndex: -1 }), /tabindex="-1"/);
@@ -50,6 +50,12 @@ export interface FolderRowProps {
50
50
  current?: boolean;
51
51
  /** Inline tag shown on the current row, e.g. `current`. */
52
52
  currentTag?: string;
53
+ /**
54
+ * How much mail the folder holds, right-aligned. The accessible name is the
55
+ * caller's `ariaLabel`, so a surface that needs the count announced folds it
56
+ * in there rather than relying on this.
57
+ */
58
+ messageCount?: number;
53
59
  /** A hairline under the label, drawn for every row but the last. */
54
60
  separated?: boolean;
55
61
  /**
@@ -77,6 +83,7 @@ export const FolderRow = ({
77
83
  context = false,
78
84
  current = false,
79
85
  currentTag,
86
+ messageCount,
80
87
  separated = false,
81
88
  actions,
82
89
  tabIndex,
@@ -96,6 +103,12 @@ export const FolderRow = ({
96
103
  const icon = (
97
104
  <Folder className="size-4 shrink-0 text-fg-subtle" aria-hidden="true" />
98
105
  );
106
+ const count =
107
+ messageCount === undefined ? null : (
108
+ <span aria-hidden="true" className="shrink-0 text-2xs text-fg-subtle">
109
+ {messageCount} {messageCount === 1 ? "msg" : "msgs"}
110
+ </span>
111
+ );
99
112
  if (context) {
100
113
  return (
101
114
  <div className="relative flex items-center">
@@ -112,6 +125,7 @@ export const FolderRow = ({
112
125
  {chevron}
113
126
  {icon}
114
127
  <span className="min-w-0 flex-1 truncate">{label}</span>
128
+ {count}
115
129
  </div>
116
130
  {actions}
117
131
  {separated && <FolderRowSeparator depth={depth} />}
@@ -142,6 +156,7 @@ export const FolderRow = ({
142
156
  {chevron}
143
157
  {icon}
144
158
  <span className="min-w-0 flex-1 truncate">{label}</span>
159
+ {count}
145
160
  {current && currentTag && (
146
161
  <span className="shrink-0 text-xs text-fg-muted">{currentTag}</span>
147
162
  )}
@@ -101,8 +101,20 @@ describe("FolderTreePicker render", () => {
101
101
 
102
102
  it("applies caller-supplied labels", () => {
103
103
  const html = render({
104
- labels: { optionLabel: (label) => `Verplaats naar ${label}` },
104
+ labels: { optionLabel: (folder) => `Verplaats naar ${folder.label}` },
105
105
  });
106
106
  assert.match(html, /aria-label="Verplaats naar Archive"/);
107
107
  });
108
+
109
+ it("announces the message count as part of the row's own name", () => {
110
+ const html = render({
111
+ folders: [{ id: "t", label: "Trash", path: "Trash", messageCount: 512 }],
112
+ labels: {
113
+ optionLabel: (folder) =>
114
+ `Set ${folder.label}, ${folder.messageCount} messages, as Trash`,
115
+ },
116
+ });
117
+ assert.match(html, /aria-label="Set Trash, 512 messages, as Trash"/);
118
+ assert.match(html, />512</);
119
+ });
108
120
  });
@@ -55,8 +55,12 @@ export interface FolderTreePickerLabels {
55
55
  emptyMessage?: (query: string) => string;
56
56
  /** Shown when there is no folder to list at all, filter or no filter. */
57
57
  noFolders?: string;
58
- /** Accessible label for a selectable row, e.g. `Move to X`. */
59
- optionLabel?: (label: string) => string;
58
+ /**
59
+ * Accessible label for a selectable row. Takes the folder rather than its
60
+ * label, so a surface can announce the message count as part of the name
61
+ * instead of leaving it as decoration beside it.
62
+ */
63
+ optionLabel?: (folder: FolderTreeNode) => string;
60
64
  newFolder?: string;
61
65
  newSubfolder?: (label: string) => string;
62
66
  nameLabel?: string;
@@ -113,7 +117,7 @@ const defaultLabels: Required<FolderTreePickerLabels> = {
113
117
  contextSuffix: "(containing folder)",
114
118
  emptyMessage: (query) => `No folders match "${query}"`,
115
119
  noFolders: "No folders to show",
116
- optionLabel: (label) => `Move to ${label}`,
120
+ optionLabel: (folder) => `Move to ${folder.label}`,
117
121
  newFolder: "New folder",
118
122
  newSubfolder: (label) => `New folder inside ${label}`,
119
123
  topLevel: "Top level",
@@ -376,7 +380,7 @@ export const FolderTreePicker = ({
376
380
 
377
381
  const rowAriaLabel = (row: FolderTreeRow): string => {
378
382
  if (row.context) return `${row.folder.label} ${text.contextSuffix}`;
379
- if (isSelectable(row)) return text.optionLabel(row.folder.label);
383
+ if (isSelectable(row)) return text.optionLabel(row.folder);
380
384
  return `${row.folder.label} ${text.currentSuffix}`;
381
385
  };
382
386
 
@@ -394,6 +398,7 @@ export const FolderTreePicker = ({
394
398
  context={row.context}
395
399
  current={folder.isCurrent}
396
400
  currentTag={text.currentTag}
401
+ messageCount={folder.messageCount}
397
402
  selected={selectable && folder.id === selectedId}
398
403
  separated={separated}
399
404
  actions={rowActions?.(folder)}
@@ -4,28 +4,46 @@ import { createElement } from "react";
4
4
  import { renderToString } from "react-dom/server";
5
5
  import {
6
6
  type CandidateFolder,
7
+ type RoleAppointment,
7
8
  RoleAppointmentList,
8
9
  } from "./role-appointment-list.js";
9
10
 
10
11
  const noop = () => {};
11
12
 
12
13
  const folders: CandidateFolder[] = [
13
- { mailboxId: "mb-inbox", providerPath: "INBOX", messageCount: 4821 },
14
+ {
15
+ mailboxId: "mb-inbox",
16
+ providerPath: "INBOX",
17
+ hierarchyDelimiter: "/",
18
+ messageCount: 4821,
19
+ },
14
20
  {
15
21
  mailboxId: "mb-concepten",
16
22
  providerPath: "INBOX/Concepten",
23
+ hierarchyDelimiter: "/",
17
24
  messageCount: 340,
18
25
  },
19
- { mailboxId: "mb-drafts", providerPath: "INBOX/Drafts", messageCount: 0 },
26
+ {
27
+ mailboxId: "mb-drafts",
28
+ providerPath: "INBOX/Drafts",
29
+ hierarchyDelimiter: "/",
30
+ messageCount: 0,
31
+ },
20
32
  {
21
33
  mailboxId: "mb-news",
22
34
  providerPath: "INBOX/Nieuwsbrieven",
35
+ hierarchyDelimiter: "/",
23
36
  messageCount: 2870,
24
37
  },
25
38
  ];
26
39
 
40
+ const appointed = (mailboxId: string): RoleAppointment => ({
41
+ mailboxId,
42
+ source: "Appointed",
43
+ });
44
+
27
45
  function render(
28
- appointments: Record<string, string | null>,
46
+ appointments: Record<string, RoleAppointment>,
29
47
  displayNames: Record<string, string> = {},
30
48
  ): string {
31
49
  return renderToString(
@@ -42,7 +60,7 @@ function render(
42
60
 
43
61
  describe("RoleAppointmentList", () => {
44
62
  it("titles the section with the account email", () => {
45
- const html = render({ inbox: "mb-inbox" });
63
+ const html = render({ inbox: appointed("mb-inbox") });
46
64
  assert.match(html, /Folder roles —/);
47
65
  assert.match(html, /you@example.com/);
48
66
  });
@@ -69,29 +87,203 @@ describe("RoleAppointmentList", () => {
69
87
  });
70
88
 
71
89
  it("shows the appointed folder's path and count under the role", () => {
72
- const html = render({ drafts: "mb-concepten" });
90
+ const html = render({ drafts: appointed("mb-concepten") });
73
91
  assert.match(html, /title="INBOX\/Concepten"/);
74
92
  assert.match(html, /340/);
75
93
  assert.match(html, /messages/);
76
94
  });
77
95
 
78
96
  it("renders a rename field only for an appointed role", () => {
79
- const html = render({ drafts: "mb-concepten" });
97
+ const html = render({ drafts: appointed("mb-concepten") });
80
98
  assert.match(html, /Display name for Drafts/);
81
99
  assert.doesNotMatch(html, /Display name for Sent/);
82
100
  });
83
101
 
84
102
  it("lists unappointed folders under Other folders", () => {
85
- const html = render({ drafts: "mb-concepten", inbox: "mb-inbox" });
103
+ const html = render({
104
+ drafts: appointed("mb-concepten"),
105
+ inbox: appointed("mb-inbox"),
106
+ });
86
107
  assert.match(html, /Other folders/);
87
108
  assert.match(html, /Nieuwsbrieven/);
88
109
  assert.match(html, /Drafts/);
89
110
  });
90
111
 
91
112
  it("keeps an appointed folder out of the Other folders list", () => {
92
- const html = render({ drafts: "mb-concepten" });
113
+ const html = render({ drafts: appointed("mb-concepten") });
93
114
  const otherIdx = html.indexOf("Other folders");
94
115
  assert.ok(otherIdx >= 0);
95
116
  assert.doesNotMatch(html.slice(otherIdx), /Concepten/);
96
117
  });
118
+
119
+ it("says where each answer came from, in front of the path", () => {
120
+ assert.match(
121
+ render({ drafts: appointed("mb-concepten") }),
122
+ /Chosen by you · INBOX\/Concepten/,
123
+ );
124
+ assert.match(
125
+ render({ drafts: { mailboxId: "mb-concepten", source: "Flagged" } }),
126
+ /The mail server marks it as Drafts · INBOX\/Concepten/,
127
+ );
128
+ assert.match(
129
+ render({ inbox: { mailboxId: "mb-inbox", source: "Reserved" } }),
130
+ /The account&#x27;s own INBOX · INBOX/,
131
+ );
132
+ assert.match(
133
+ render({ drafts: { mailboxId: "mb-concepten", source: "Proposed" } }),
134
+ /Matched by name, not confirmed · INBOX\/Concepten/,
135
+ );
136
+ });
137
+
138
+ it("announces the provenance with the control that changes it", () => {
139
+ const html = render({ drafts: appointed("mb-concepten") });
140
+ const described = /aria-describedby="([^"]+)"/.exec(html);
141
+ assert.ok(described);
142
+ assert.match(html, new RegExp(`id="${described[1]}"`));
143
+ });
144
+
145
+ it("reads an unfilled role as a decision waiting, not an error", () => {
146
+ const html = render({ archive: { mailboxId: null, source: "None" } });
147
+ assert.match(
148
+ html,
149
+ /Not set — pick the folder this account uses for Archive\.</,
150
+ );
151
+ });
152
+
153
+ it("says only Trash gates a verb when it is unfilled", () => {
154
+ const html = render({ trash: { mailboxId: null, source: "None" } });
155
+ assert.match(html, /Deleting mail needs one\./);
156
+ });
157
+
158
+ it("calls out a stale Trash with the folder that vanished and its repair", () => {
159
+ const html = render({
160
+ trash: {
161
+ mailboxId: null,
162
+ source: "Stale",
163
+ staleFolderPath: "INBOX/Prullenbak",
164
+ },
165
+ });
166
+ assert.match(
167
+ html,
168
+ /The folder you chose for Trash — INBOX\/Prullenbak — is gone from the mail server\./,
169
+ );
170
+ assert.match(html, /Deleting mail is stopped until you pick another one\./);
171
+ assert.match(html, /Pick a folder/);
172
+ assert.match(html, /role="alert"/);
173
+ });
174
+
175
+ it("names what reader fell back to for a stale non-Trash role", () => {
176
+ const html = render({
177
+ drafts: {
178
+ mailboxId: "mb-drafts",
179
+ source: "Stale",
180
+ staleFolderPath: "INBOX/Concepten",
181
+ },
182
+ });
183
+ assert.match(
184
+ html,
185
+ /The folder you chose for Drafts — INBOX\/Concepten — is gone from the mail server\./,
186
+ );
187
+ assert.match(html, /reader is using Drafts instead\./);
188
+ assert.doesNotMatch(html, /Deleting mail is stopped/);
189
+ });
190
+
191
+ it("drops the name clause when no path was ever recorded", () => {
192
+ const html = render({ trash: { mailboxId: null, source: "Stale" } });
193
+ assert.match(
194
+ html,
195
+ /The folder you chose for Trash is gone from the mail server\./,
196
+ );
197
+ });
198
+
199
+ it("says nothing about a fallback it cannot name", () => {
200
+ const html = render({
201
+ drafts: {
202
+ mailboxId: null,
203
+ source: "Stale",
204
+ staleFolderPath: "INBOX/Concepten",
205
+ },
206
+ });
207
+ assert.match(html, /is gone from the mail server\.</);
208
+ assert.doesNotMatch(html, /reader is using/);
209
+ });
210
+
211
+ it("announces the stale callout with the control that repairs it", () => {
212
+ const html = render({
213
+ trash: {
214
+ mailboxId: null,
215
+ source: "Stale",
216
+ staleFolderPath: "INBOX/Prullenbak",
217
+ },
218
+ });
219
+ const notice = /id="([^"]+)"[^>]*>The folder you chose for Trash/.exec(
220
+ html,
221
+ );
222
+ assert.ok(notice, "the callout carries an id");
223
+ assert.match(
224
+ html,
225
+ new RegExp(`aria-describedby="${notice[1]}"`),
226
+ "the Select that repairs the role points at it",
227
+ );
228
+ });
229
+ });
230
+
231
+ describe("RoleAppointmentList — the server’s own delimiter (#877)", () => {
232
+ const dotted: CandidateFolder[] = [
233
+ {
234
+ mailboxId: "mb-inbox",
235
+ providerPath: "INBOX",
236
+ hierarchyDelimiter: ".",
237
+ messageCount: 4821,
238
+ },
239
+ {
240
+ mailboxId: "mb-concepten",
241
+ providerPath: "INBOX.Projects.Concepten",
242
+ hierarchyDelimiter: ".",
243
+ messageCount: 340,
244
+ },
245
+ {
246
+ mailboxId: "mb-news",
247
+ providerPath: "INBOX.Projects.Nieuwsbrieven",
248
+ hierarchyDelimiter: ".",
249
+ messageCount: 2870,
250
+ },
251
+ ];
252
+
253
+ const renderDotted = (
254
+ appointments: Record<string, RoleAppointment>,
255
+ ): string =>
256
+ renderToString(
257
+ createElement(RoleAppointmentList, {
258
+ accountEmail: "you@example.com",
259
+ folders: dotted,
260
+ appointments,
261
+ displayNames: {},
262
+ onAppoint: noop,
263
+ onRename: noop,
264
+ }),
265
+ );
266
+
267
+ it("offers the picker option by its leaf", () => {
268
+ assert.match(renderDotted({}), /Concepten · 340 msgs/);
269
+ assert.doesNotMatch(renderDotted({}), /INBOX.Projects.Concepten · 340/);
270
+ });
271
+
272
+ it("names the stale-appointment fallback by its leaf", () => {
273
+ const html = renderDotted({
274
+ drafts: {
275
+ mailboxId: "mb-concepten",
276
+ source: "Stale",
277
+ staleFolderPath: "INBOX.Projects.Oud",
278
+ },
279
+ });
280
+ assert.match(html, /reader is using Concepten instead\./);
281
+ });
282
+
283
+ it("lists an unappointed folder by its leaf under Other folders", () => {
284
+ const html = renderDotted({ drafts: appointed("mb-concepten") });
285
+ const other = html.slice(html.indexOf("Other folders"));
286
+ assert.match(other, />Nieuwsbrieven</);
287
+ assert.doesNotMatch(other, />INBOX.Projects.Nieuwsbrieven</);
288
+ });
97
289
  });
@@ -3,6 +3,7 @@ import { useState } from "react";
3
3
  import type { FolderRole } from "./folder-role.js";
4
4
  import {
5
5
  type CandidateFolder,
6
+ type RoleAppointment,
6
7
  RoleAppointmentList,
7
8
  } from "./role-appointment-list.js";
8
9
 
@@ -17,57 +18,101 @@ import {
17
18
  * numbers are wired in.
18
19
  */
19
20
  const HOSTNET_FOLDERS: readonly CandidateFolder[] = [
20
- { mailboxId: "mb-inbox", providerPath: "INBOX", messageCount: 4821 },
21
+ {
22
+ mailboxId: "mb-inbox",
23
+ providerPath: "INBOX",
24
+ hierarchyDelimiter: "/",
25
+ messageCount: 4821,
26
+ },
21
27
  {
22
28
  mailboxId: "mb-archive",
23
29
  providerPath: "INBOX/Archive",
30
+ hierarchyDelimiter: "/",
24
31
  messageCount: 19243,
25
32
  },
26
33
  {
27
34
  mailboxId: "mb-concepten",
28
35
  providerPath: "INBOX/Concepten",
36
+ hierarchyDelimiter: "/",
29
37
  messageCount: 340,
30
38
  },
31
39
  {
32
40
  mailboxId: "mb-deleted",
33
41
  providerPath: "INBOX/Deleted Messages",
42
+ hierarchyDelimiter: "/",
34
43
  messageCount: 512,
35
44
  },
36
- { mailboxId: "mb-drafts", providerPath: "INBOX/Drafts", messageCount: 0 },
45
+ {
46
+ mailboxId: "mb-drafts",
47
+ providerPath: "INBOX/Drafts",
48
+ hierarchyDelimiter: "/",
49
+ messageCount: 0,
50
+ },
37
51
  {
38
52
  mailboxId: "mb-news",
39
53
  providerPath: "INBOX/Nieuwsbrieven",
54
+ hierarchyDelimiter: "/",
40
55
  messageCount: 2870,
41
56
  },
42
- { mailboxId: "mb-sent", providerPath: "INBOX/Sent", messageCount: 0 },
57
+ {
58
+ mailboxId: "mb-sent",
59
+ providerPath: "INBOX/Sent",
60
+ hierarchyDelimiter: "/",
61
+ messageCount: 0,
62
+ },
43
63
  {
44
64
  mailboxId: "mb-sent-messages",
45
65
  providerPath: "INBOX/Sent Messages",
66
+ hierarchyDelimiter: "/",
46
67
  messageCount: 6105,
47
68
  },
48
- { mailboxId: "mb-spam", providerPath: "INBOX/Spam", messageCount: 88 },
69
+ {
70
+ mailboxId: "mb-spam",
71
+ providerPath: "INBOX/Spam",
72
+ hierarchyDelimiter: "/",
73
+ messageCount: 88,
74
+ },
49
75
  ];
50
76
 
77
+ const appointed = (mailboxId: string): RoleAppointment => ({
78
+ mailboxId,
79
+ source: "Appointed",
80
+ });
81
+
82
+ /** Everything except the role each story is about, so one row carries the news. */
83
+ const SETTLED: Record<string, RoleAppointment> = {
84
+ inbox: { mailboxId: "mb-inbox", source: "Reserved" },
85
+ drafts: appointed("mb-concepten"),
86
+ sent: appointed("mb-sent-messages"),
87
+ archive: appointed("mb-archive"),
88
+ junk: appointed("mb-spam"),
89
+ trash: appointed("mb-deleted"),
90
+ };
91
+
51
92
  function Harness({
52
93
  folders,
53
94
  initial,
54
95
  }: {
55
96
  folders: readonly CandidateFolder[];
56
- initial: Record<string, string | null>;
97
+ initial: Record<string, RoleAppointment>;
57
98
  }) {
58
99
  const [appointments, setAppointments] = useState(initial);
59
100
  const [displayNames, setDisplayNames] = useState<Record<string, string>>({});
60
101
 
61
102
  const handleAppoint = (role: FolderRole, mailboxId: string | null) => {
62
103
  setAppointments((prev) => {
63
- const next: Record<string, string | null> = {
104
+ const next: Record<string, RoleAppointment> = {
64
105
  ...prev,
65
- [role]: mailboxId,
106
+ [role]: mailboxId
107
+ ? { mailboxId, source: "Appointed" }
108
+ : { mailboxId: null, source: "None" },
66
109
  };
67
110
  // Exclusivity: appointing a folder to one role clears it from any other.
68
111
  if (mailboxId) {
69
- for (const r of Object.keys(next)) {
70
- if (r !== role && next[r] === mailboxId) next[r] = null;
112
+ for (const other of Object.keys(next)) {
113
+ if (other === role) continue;
114
+ if (next[other]?.mailboxId !== mailboxId) continue;
115
+ next[other] = { mailboxId: null, source: "None" };
71
116
  }
72
117
  }
73
118
  return next;
@@ -105,34 +150,96 @@ type Story = StoryObj<typeof Harness>;
105
150
  * The empty look-alikes drop to "Other folders".
106
151
  */
107
152
  export const Hostnet: Story = {
153
+ args: { folders: HOSTNET_FOLDERS, initial: SETTLED },
154
+ };
155
+
156
+ /**
157
+ * Flag-first proposal before the user corrects it: detection appointed the
158
+ * `\Drafts`-flagged but empty `INBOX/Drafts` (0) and the empty `INBOX/Sent`.
159
+ * The picker counts reveal the real folders so the user can re-appoint.
160
+ */
161
+ export const ProposedDefaults: Story = {
108
162
  args: {
109
163
  folders: HOSTNET_FOLDERS,
110
164
  initial: {
111
- inbox: "mb-inbox",
112
- drafts: "mb-concepten",
113
- sent: "mb-sent-messages",
114
- archive: "mb-archive",
115
- junk: "mb-spam",
116
- trash: "mb-deleted",
165
+ ...SETTLED,
166
+ drafts: { mailboxId: "mb-drafts", source: "Flagged" },
167
+ sent: { mailboxId: "mb-sent", source: "Flagged" },
168
+ },
169
+ },
170
+ };
171
+
172
+ /** `Appointed` — a person decided, and the row says so. */
173
+ export const AppointedSource: Story = {
174
+ name: "appointed",
175
+ args: { folders: HOSTNET_FOLDERS, initial: SETTLED },
176
+ };
177
+
178
+ /** `Flagged` — the mail server's own SPECIAL-USE flag, not a guess. */
179
+ export const FlaggedSource: Story = {
180
+ name: "flagged",
181
+ args: {
182
+ folders: HOSTNET_FOLDERS,
183
+ initial: {
184
+ ...SETTLED,
185
+ trash: { mailboxId: "mb-deleted", source: "Flagged" },
186
+ },
187
+ },
188
+ };
189
+
190
+ /** `Reserved` — INBOX is the one role the protocol names for us. */
191
+ export const ReservedSource: Story = {
192
+ name: "reserved",
193
+ args: {
194
+ folders: HOSTNET_FOLDERS,
195
+ initial: {
196
+ ...SETTLED,
197
+ inbox: { mailboxId: "mb-inbox", source: "Reserved" },
198
+ },
199
+ },
200
+ };
201
+
202
+ /** `Proposed` — a name matched. Nobody confirmed it, and the row does not pretend otherwise. */
203
+ export const ProposedSource: Story = {
204
+ name: "proposed",
205
+ args: {
206
+ folders: HOSTNET_FOLDERS,
207
+ initial: {
208
+ ...SETTLED,
209
+ trash: { mailboxId: "mb-deleted", source: "Proposed" },
117
210
  },
118
211
  },
119
212
  };
120
213
 
121
214
  /**
122
- * Flag-first proposal before the user corrects it: detection appointed the
123
- * `\Drafts`-flagged but empty `INBOX/Drafts` (0) and the empty `INBOX/Sent`.
124
- * The picker counts reveal the real folders so the user can re-appoint.
215
+ * `Stale` the folder the user chose is gone from the mail server. The one row
216
+ * representing a broken decision, so it is a callout with its repair rather
217
+ * than a subtitle. Deleting mail is stopped until Trash is repaired.
125
218
  */
126
- export const ProposedDefaults: Story = {
219
+ export const StaleSource: Story = {
220
+ name: "stale",
221
+ args: {
222
+ folders: HOSTNET_FOLDERS,
223
+ initial: {
224
+ ...SETTLED,
225
+ trash: {
226
+ mailboxId: null,
227
+ source: "Stale",
228
+ staleFolderPath: "INBOX/Prullenbak",
229
+ },
230
+ },
231
+ },
232
+ };
233
+
234
+ /** `None` — a decision waiting to be made. No icon, no danger colour. */
235
+ export const NoneSource: Story = {
236
+ name: "none",
127
237
  args: {
128
238
  folders: HOSTNET_FOLDERS,
129
239
  initial: {
130
- inbox: "mb-inbox",
131
- drafts: "mb-drafts",
132
- sent: "mb-sent",
133
- archive: "mb-archive",
134
- junk: "mb-spam",
135
- trash: "mb-deleted",
240
+ ...SETTLED,
241
+ trash: { mailboxId: null, source: "None" },
242
+ archive: { mailboxId: null, source: "None" },
136
243
  },
137
244
  },
138
245
  };