@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.
@@ -1,10 +1,11 @@
1
1
  import { Check, Folder } from "lucide-react";
2
- import { useState } from "react";
2
+ import { useId, useRef, useState } from "react";
3
+ import { folderLeaf } from "../lib/folder-tree.js";
4
+ import { Banner } from "./banner.js";
3
5
  import { Button } from "./button.js";
4
6
  import {
5
7
  canonicalRoleLabel,
6
8
  type FolderRole,
7
- providerLeaf,
8
9
  roleIcon,
9
10
  } from "./folder-role.js";
10
11
  import { Input } from "./input.js";
@@ -34,24 +35,63 @@ export interface CandidateFolder {
34
35
  mailboxId: string;
35
36
  /** Server truth — the IMAP path. Read-only. */
36
37
  providerPath: string;
38
+ /** How the folder's own server separates path segments; `""` means flat. */
39
+ hierarchyDelimiter: string;
37
40
  /** Live message count, so the user can pick the folder that holds mail. */
38
41
  messageCount: number;
39
42
  }
40
43
 
44
+ /** Where a role's answer came from (#887). */
45
+ export type RoleAppointmentSource =
46
+ | "Appointed"
47
+ | "Flagged"
48
+ | "Reserved"
49
+ | "Proposed"
50
+ | "Stale"
51
+ | "None";
52
+
53
+ export interface RoleAppointment {
54
+ /** The mailbox the role resolves to, or null when it resolves to none. */
55
+ mailboxId: string | null;
56
+ source: RoleAppointmentSource;
57
+ /** `Stale` only: the path the folder the user chose last had. */
58
+ staleFolderPath?: string;
59
+ }
60
+
41
61
  /** Empty <option> value standing for "no folder appointed". */
42
62
  const NONE = "";
43
63
 
64
+ const UNRESOLVED: RoleAppointment = { mailboxId: null, source: "None" };
65
+
44
66
  /** Picker option text: `Concepten · 340 msgs`. */
45
67
  function folderOptionLabel(folder: CandidateFolder): string {
46
68
  const noun = folder.messageCount === 1 ? "msg" : "msgs";
47
- return `${providerLeaf(folder.providerPath)} · ${folder.messageCount} ${noun}`;
69
+ const leaf = folderLeaf(folder.providerPath, folder.hierarchyDelimiter);
70
+ return `${leaf} · ${folder.messageCount} ${noun}`;
48
71
  }
49
72
 
73
+ const messages = (count: number): string =>
74
+ `${count} ${count === 1 ? "message" : "messages"}`;
75
+
76
+ /**
77
+ * The provenance clause in front of the path and count. Read the source rather
78
+ * than inferring it from the shape of the row: only `Appointed` means a person
79
+ * decided, and `Proposed` is a name match nobody confirmed.
80
+ */
81
+ const provenanceClause = (
82
+ source: RoleAppointmentSource,
83
+ roleLabel: string,
84
+ ): string => {
85
+ if (source === "Appointed") return "Chosen by you";
86
+ if (source === "Flagged") return `The mail server marks it as ${roleLabel}`;
87
+ if (source === "Reserved") return "The account's own INBOX";
88
+ return "Matched by name, not confirmed";
89
+ };
90
+
50
91
  interface RoleAppointmentRowProps {
51
92
  role: FolderRole;
52
93
  folders: readonly CandidateFolder[];
53
- /** The mailbox appointed to this role, or null when the role is unfilled. */
54
- appointedId: string | null;
94
+ appointment: RoleAppointment;
55
95
  /** Committed display-name override for the appointed folder. */
56
96
  displayName: string;
57
97
  onAppoint: (role: FolderRole, mailboxId: string | null) => void;
@@ -64,21 +104,65 @@ interface RoleAppointmentRowProps {
64
104
  * folder that actually holds mail), and — once a folder is appointed — a rename
65
105
  * field for its sidebar label. Selecting a folder here clears it from any other
66
106
  * role on write; the picker can never produce a duplicate.
107
+ *
108
+ * The line under the control says where the row's answer came from, and is
109
+ * pointed at by the Select's `aria-describedby` so the provenance is announced
110
+ * with the control that changes it.
67
111
  */
68
112
  function RoleAppointmentRow({
69
113
  role,
70
114
  folders,
71
- appointedId,
115
+ appointment,
72
116
  displayName,
73
117
  onAppoint,
74
118
  onRename,
75
119
  }: RoleAppointmentRowProps) {
76
120
  const [draftName, setDraftName] = useState(displayName);
77
- const appointed = folders.find((f) => f.mailboxId === appointedId) ?? null;
121
+ const selectRef = useRef<HTMLSelectElement>(null);
122
+ const subtitleId = useId();
123
+ const { mailboxId, source, staleFolderPath } = appointment;
124
+ const appointed = folders.find((f) => f.mailboxId === mailboxId) ?? null;
78
125
  const label = canonicalRoleLabel(role);
79
126
  const renameDirty =
80
127
  appointed != null && draftName.trim() !== displayName.trim();
81
128
 
129
+ const subtitle = (() => {
130
+ if (source === "None") {
131
+ const needsOne = role === "trash" ? " Deleting mail needs one." : "";
132
+ return (
133
+ <span id={subtitleId} className="pl-[7.5rem] text-xs text-fg-muted">
134
+ {`Not set — pick the folder this account uses for ${label}.${needsOne}`}
135
+ </span>
136
+ );
137
+ }
138
+ if (source === "Stale" || !appointed) return null;
139
+ return (
140
+ <span
141
+ id={subtitleId}
142
+ className="truncate pl-[7.5rem] text-2xs text-fg-subtle"
143
+ title={appointed.providerPath}
144
+ >
145
+ {`${provenanceClause(source, label)} · ${appointed.providerPath} · ${messages(appointed.messageCount)}`}
146
+ </span>
147
+ );
148
+ })();
149
+
150
+ // Trash is the one role where a broken appointment stops a verb; the others
151
+ // fall back to a live folder. With no fallback to name, the sentence stops
152
+ // rather than inventing one.
153
+ const staleFallback =
154
+ role === "trash"
155
+ ? " Deleting mail is stopped until you pick another one."
156
+ : appointed
157
+ ? ` reader is using ${folderLeaf(
158
+ appointed.providerPath,
159
+ appointed.hierarchyDelimiter,
160
+ )} instead.`
161
+ : "";
162
+ const staleNotice = `The folder you chose for ${label}${
163
+ staleFolderPath ? ` — ${staleFolderPath} —` : ""
164
+ } is gone from the mail server.${staleFallback}`;
165
+
82
166
  return (
83
167
  <div className="flex flex-col gap-1 border-b border-line px-row-inset py-2.5 last:border-b-0">
84
168
  <div className="flex items-center gap-2">
@@ -87,9 +171,15 @@ function RoleAppointmentRow({
87
171
  {label}
88
172
  </span>
89
173
  <Select
174
+ ref={selectRef}
90
175
  className="w-56 shrink-0"
91
- value={appointedId ?? NONE}
176
+ value={mailboxId ?? NONE}
92
177
  aria-label={`Folder for ${label}`}
178
+ // Whichever line the row renders — the provenance subtitle or the
179
+ // stale callout — is the one that explains this control.
180
+ aria-describedby={
181
+ subtitle || source === "Stale" ? subtitleId : undefined
182
+ }
93
183
  onChange={(event) =>
94
184
  onAppoint(
95
185
  role,
@@ -126,14 +216,23 @@ function RoleAppointmentRow({
126
216
  </>
127
217
  )}
128
218
  </div>
129
- {appointed && (
130
- <span
131
- className="truncate pl-[7.5rem] text-2xs text-fg-subtle"
132
- title={appointed.providerPath}
133
- >
134
- {appointed.providerPath} · {appointed.messageCount}{" "}
135
- {appointed.messageCount === 1 ? "message" : "messages"}
136
- </span>
219
+ {subtitle}
220
+ {source === "Stale" && (
221
+ <Banner tone="warning" variant="soft" className="ml-[7.5rem]">
222
+ <span className="flex flex-wrap items-center gap-2">
223
+ <span id={subtitleId} className="flex-1">
224
+ {staleNotice}
225
+ </span>
226
+ <Button
227
+ variant="secondary"
228
+ size="sm"
229
+ className="shrink-0"
230
+ onClick={() => selectRef.current?.focus()}
231
+ >
232
+ Pick a folder
233
+ </Button>
234
+ </span>
235
+ </Banner>
137
236
  )}
138
237
  </div>
139
238
  );
@@ -143,8 +242,8 @@ export interface RoleAppointmentListProps {
143
242
  accountEmail: string;
144
243
  /** Every folder the account exposes (candidates for any role). */
145
244
  folders: readonly CandidateFolder[];
146
- /** role → appointed mailboxId. A missing role means "None". */
147
- appointments: Readonly<Record<string, string | null>>;
245
+ /** role → what fills it and where that answer came from. */
246
+ appointments: Readonly<Record<string, RoleAppointment>>;
148
247
  /** mailboxId → committed display-name override. */
149
248
  displayNames?: Readonly<Record<string, string>>;
150
249
  onAppoint: (role: FolderRole, mailboxId: string | null) => void;
@@ -167,7 +266,9 @@ export function RoleAppointmentList({
167
266
  onRename,
168
267
  }: RoleAppointmentListProps) {
169
268
  const appointedIds = new Set(
170
- Object.values(appointments).filter((id): id is string => id != null),
269
+ Object.values(appointments)
270
+ .map((appointment) => appointment.mailboxId)
271
+ .filter((id): id is string => id != null),
171
272
  );
172
273
  const leftovers = folders.filter((f) => !appointedIds.has(f.mailboxId));
173
274
 
@@ -180,19 +281,25 @@ export function RoleAppointmentList({
180
281
  <p className="text-xs text-fg-muted">
181
282
  Each role points to one folder. Pick the folder that holds the mail —
182
283
  the counts help you tell real folders from empty look-alikes.
183
- Appointing a folder here removes it from any other role.
284
+ Appointing a folder here removes it from any other role. Each row says
285
+ where its answer came from — your choice, the mail server's own flag,
286
+ or a name reader matched.
184
287
  </p>
185
288
  </header>
186
289
  <div className="rounded-sm border border-line bg-surface">
187
290
  {APPOINTABLE_ROLES.map((role) => {
188
- const appointedId = appointments[role] ?? null;
291
+ const appointment = appointments[role] ?? UNRESOLVED;
189
292
  return (
190
293
  <RoleAppointmentRow
191
294
  key={role}
192
295
  role={role}
193
296
  folders={folders}
194
- appointedId={appointedId}
195
- displayName={appointedId ? (displayNames[appointedId] ?? "") : ""}
297
+ appointment={appointment}
298
+ displayName={
299
+ appointment.mailboxId
300
+ ? (displayNames[appointment.mailboxId] ?? "")
301
+ : ""
302
+ }
196
303
  onAppoint={onAppoint}
197
304
  onRename={onRename}
198
305
  />
@@ -211,11 +318,10 @@ export function RoleAppointmentList({
211
318
  >
212
319
  <Folder className="size-4 shrink-0 text-fg-subtle" />
213
320
  <span className="truncate">
214
- {providerLeaf(folder.providerPath)}
321
+ {folderLeaf(folder.providerPath, folder.hierarchyDelimiter)}
215
322
  </span>
216
323
  <span className="ml-auto shrink-0 text-2xs text-fg-subtle">
217
- {folder.messageCount}{" "}
218
- {folder.messageCount === 1 ? "message" : "messages"}
324
+ {messages(folder.messageCount)}
219
325
  </span>
220
326
  </li>
221
327
  ))}
@@ -0,0 +1,240 @@
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 type { FolderTreeNode } from "../lib/folder-tree.js";
6
+ import {
7
+ RoleAppointmentPrompt,
8
+ type RoleAppointmentPromptProps,
9
+ roleAppointmentPromptCopy,
10
+ } from "./role-appointment-prompt.js";
11
+
12
+ const folders: FolderTreeNode[] = [
13
+ { id: "mb-inbox", label: "INBOX", path: "INBOX", messageCount: 4821 },
14
+ {
15
+ id: "mb-deleted",
16
+ label: "Deleted Messages",
17
+ path: "Deleted Messages",
18
+ messageCount: 512,
19
+ },
20
+ {
21
+ id: "mb-prullenbak",
22
+ label: "Prullenbak",
23
+ path: "Prullenbak",
24
+ messageCount: 0,
25
+ },
26
+ ];
27
+
28
+ const noop = () => {};
29
+
30
+ const render = (props: Partial<RoleAppointmentPromptProps>) =>
31
+ renderToString(
32
+ createElement(RoleAppointmentPrompt, {
33
+ open: true,
34
+ reason: "none",
35
+ action: { kind: "delete", count: 12 },
36
+ folders,
37
+ delimiter: "/",
38
+ phase: { kind: "choosing" },
39
+ onSelect: noop,
40
+ onConfirm: noop,
41
+ onCancel: noop,
42
+ ...props,
43
+ }),
44
+ );
45
+
46
+ describe("roleAppointmentPromptCopy", () => {
47
+ it("says nothing was deleted, and what reader files deletes in", () => {
48
+ const copy = roleAppointmentPromptCopy("none", {
49
+ kind: "delete",
50
+ count: 12,
51
+ });
52
+ assert.equal(copy.title, "No folder is set as Trash");
53
+ assert.equal(
54
+ copy.description,
55
+ "Nothing has been deleted. reader files deleted mail in the folder you set as Trash, and this account has none.",
56
+ );
57
+ assert.equal(
58
+ copy.pickerPrompt,
59
+ "Which folder does this account use for deleted mail?",
60
+ );
61
+ assert.equal(copy.confirmLabel, "Set as Trash and delete 12 messages");
62
+ });
63
+
64
+ it("counts one message as one message", () => {
65
+ const copy = roleAppointmentPromptCopy("none", {
66
+ kind: "delete",
67
+ count: 1,
68
+ });
69
+ assert.equal(copy.confirmLabel, "Set as Trash and delete 1 message");
70
+ });
71
+
72
+ it("changes the verb, never the folder clause, for Empty Trash", () => {
73
+ const copy = roleAppointmentPromptCopy("none", { kind: "emptyTrash" });
74
+ assert.match(copy.description, /^Nothing has been emptied\./);
75
+ assert.match(copy.description, /and this account has none\.$/);
76
+ assert.equal(copy.confirmLabel, "Set as Trash and empty it");
77
+ });
78
+
79
+ it("names the folder that vanished", () => {
80
+ const copy = roleAppointmentPromptCopy(
81
+ "stale",
82
+ { kind: "delete", count: 3 },
83
+ { staleFolderLabel: "Prullenbak" },
84
+ );
85
+ assert.equal(copy.title, "The Trash folder you chose is gone");
86
+ assert.equal(
87
+ copy.description,
88
+ "Nothing has been deleted. You set Prullenbak as this account's Trash and it is no longer on the mail server — another mail app may have renamed or removed it.",
89
+ );
90
+ assert.equal(copy.pickerPrompt, "Which folder should reader use instead?");
91
+ });
92
+
93
+ it("degrades the stale wording when no path was ever recorded", () => {
94
+ const copy = roleAppointmentPromptCopy("stale", {
95
+ kind: "delete",
96
+ count: 3,
97
+ });
98
+ assert.equal(
99
+ copy.description,
100
+ "Nothing has been deleted. The folder you set as this account's Trash is no longer on the mail server — another mail app may have renamed or removed it.",
101
+ );
102
+ });
103
+
104
+ it("names the guess and the irreversibility when confirming a guess", () => {
105
+ const copy = roleAppointmentPromptCopy(
106
+ "unconfirmed",
107
+ { kind: "emptyTrash" },
108
+ { trashFolderLabel: "Deleted Messages", selectedCount: 512 },
109
+ );
110
+ assert.equal(copy.title, "Confirm this account's Trash folder");
111
+ assert.equal(
112
+ copy.description,
113
+ "Nothing has been emptied. reader files this account's deleted mail in Deleted Messages because of its name — you never chose it, and the mail server doesn't mark it as Trash. Emptying a folder erases everything in it from the mail server, and that cannot be restored.",
114
+ );
115
+ assert.equal(
116
+ copy.pickerPrompt,
117
+ "Confirm Deleted Messages, or pick the folder this account really uses.",
118
+ );
119
+ assert.equal(copy.confirmLabel, "Set as Trash and empty 512 messages");
120
+ });
121
+
122
+ it("falls back to an uncounted confirm when the count is unknown", () => {
123
+ const copy = roleAppointmentPromptCopy(
124
+ "unconfirmed",
125
+ { kind: "emptyTrash" },
126
+ { trashFolderLabel: "Deleted Messages" },
127
+ );
128
+ assert.equal(copy.confirmLabel, "Set as Trash and empty it");
129
+ });
130
+ });
131
+
132
+ describe("RoleAppointmentPrompt", () => {
133
+ it("renders nothing while closed", () => {
134
+ assert.equal(render({ open: false }), "");
135
+ });
136
+
137
+ it("offers a way out that changes nothing, and no confirm until one is picked", () => {
138
+ const html = render({});
139
+ assert.match(html, />Cancel</);
140
+ assert.doesNotMatch(html, /Set as Trash and delete/);
141
+ assert.doesNotMatch(html, /disabled=/);
142
+ });
143
+
144
+ it("mounts the confirm once a folder is chosen", () => {
145
+ const html = render({ selectedId: "mb-prullenbak" });
146
+ assert.match(html, /Set as Trash and delete 12 messages/);
147
+ });
148
+
149
+ it("announces each folder's count as part of the row's own name", () => {
150
+ const html = render({});
151
+ assert.match(
152
+ html,
153
+ /aria-label="Set Deleted Messages, 512 messages, as Trash"/,
154
+ );
155
+ assert.match(html, /aria-label="Folders on this account"/);
156
+ });
157
+
158
+ it("names the account only where there is more than one", () => {
159
+ assert.match(
160
+ render({ accountEmail: "you@example.com" }),
161
+ /you@example.com/,
162
+ );
163
+ assert.doesNotMatch(render({}), /you@example\.com/);
164
+ });
165
+
166
+ it("offers a new folder only where the account has no Trash at all", () => {
167
+ const create = async (): Promise<FolderTreeNode> => ({
168
+ id: "mb-new",
169
+ label: "Trash",
170
+ path: "INBOX/Trash",
171
+ });
172
+ assert.match(render({ onCreateFolder: create }), />New folder</);
173
+ assert.doesNotMatch(
174
+ render({ reason: "stale", onCreateFolder: create }),
175
+ /New folder/,
176
+ );
177
+ assert.doesNotMatch(
178
+ render({ reason: "unconfirmed", onCreateFolder: create }),
179
+ /New folder/,
180
+ );
181
+ });
182
+
183
+ it("warns in the tone of the pending action, not of the reason", () => {
184
+ assert.match(render({ reason: "none" }), /text-warning/);
185
+ assert.match(
186
+ render({ reason: "unconfirmed", action: { kind: "emptyTrash" } }),
187
+ /text-danger/,
188
+ );
189
+ });
190
+
191
+ it("replaces the body with the in-flight write, and takes the way out away", () => {
192
+ const html = render({
193
+ selectedId: "mb-prullenbak",
194
+ phase: { kind: "appointing" },
195
+ });
196
+ assert.match(html, /Setting the Trash folder…/);
197
+ assert.match(html, /data-escape-owner/);
198
+ assert.match(html, /aria-live="polite"/);
199
+ assert.doesNotMatch(html, />Cancel</);
200
+ });
201
+
202
+ it("says what the re-issued action is doing, per verb", () => {
203
+ assert.match(
204
+ render({ selectedId: "mb-prullenbak", phase: { kind: "acting" } }),
205
+ /Deleting 12 messages…/,
206
+ );
207
+ assert.match(
208
+ render({
209
+ selectedId: "mb-prullenbak",
210
+ action: { kind: "emptyTrash" },
211
+ phase: { kind: "acting" },
212
+ }),
213
+ /Emptying Prullenbak…/,
214
+ );
215
+ });
216
+
217
+ it("keeps the picker and the selection through a failed appointment", () => {
218
+ const html = render({
219
+ selectedId: "mb-prullenbak",
220
+ phase: { kind: "appoint-failed", cause: "generic" },
221
+ });
222
+ assert.match(
223
+ html,
224
+ /Couldn&#x27;t set that folder as Trash\. Nothing has been deleted\. Please try again\./,
225
+ );
226
+ assert.match(html, /Set as Trash and delete 12 messages/);
227
+ assert.match(html, /role="alert"/);
228
+ });
229
+
230
+ it("words an unsettled mailbox as a wait, not a failure to retry blindly", () => {
231
+ const html = render({
232
+ selectedId: "mb-prullenbak",
233
+ phase: { kind: "appoint-failed", cause: "mailbox-pending" },
234
+ });
235
+ assert.match(
236
+ html,
237
+ /Prullenbak is still being created on the mail server\. Wait for it to finish, then try again\./,
238
+ );
239
+ });
240
+ });
@@ -0,0 +1,172 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useEffect, useState } from "react";
3
+ import type { FolderTreeNode } from "../lib/folder-tree.js";
4
+ import {
5
+ type PromptPhase,
6
+ RoleAppointmentPrompt,
7
+ type RoleAppointmentPromptProps,
8
+ } from "./role-appointment-prompt.js";
9
+
10
+ /**
11
+ * An account with two folders that could both be Trash by name: the one holding
12
+ * 512 messages, and the empty look-alike beside it. Telling those apart is the
13
+ * whole reason the prompt shows counts.
14
+ */
15
+ const FOLDERS: readonly FolderTreeNode[] = [
16
+ { id: "mb-inbox", label: "INBOX", path: "INBOX", messageCount: 4821 },
17
+ {
18
+ id: "mb-archive",
19
+ label: "Archive",
20
+ path: "Archive",
21
+ messageCount: 19243,
22
+ },
23
+ {
24
+ id: "mb-deleted",
25
+ label: "Deleted Messages",
26
+ path: "Deleted Messages",
27
+ messageCount: 512,
28
+ },
29
+ {
30
+ id: "mb-prullenbak",
31
+ label: "Prullenbak",
32
+ path: "Prullenbak",
33
+ messageCount: 0,
34
+ },
35
+ { id: "mb-spam", label: "Spam", path: "Spam", messageCount: 88 },
36
+ ];
37
+
38
+ type HarnessProps = Omit<
39
+ RoleAppointmentPromptProps,
40
+ | "open"
41
+ | "selectedId"
42
+ | "onSelect"
43
+ | "onConfirm"
44
+ | "onCancel"
45
+ | "folders"
46
+ | "delimiter"
47
+ > & {
48
+ initialSelectedId?: string;
49
+ /** Phases the story steps through, so one story can show a sequence. */
50
+ phaseCycle?: readonly PromptPhase[];
51
+ };
52
+
53
+ function Harness({ initialSelectedId, phaseCycle, ...props }: HarnessProps) {
54
+ const [selectedId, setSelectedId] = useState(initialSelectedId);
55
+ const [step, setStep] = useState(0);
56
+
57
+ useEffect(() => {
58
+ if (!phaseCycle || phaseCycle.length < 2) return;
59
+ const timer = setInterval(
60
+ () => setStep((current) => (current + 1) % phaseCycle.length),
61
+ 2200,
62
+ );
63
+ return () => clearInterval(timer);
64
+ }, [phaseCycle]);
65
+
66
+ return (
67
+ <RoleAppointmentPrompt
68
+ {...props}
69
+ open
70
+ folders={FOLDERS}
71
+ delimiter="/"
72
+ phase={phaseCycle?.[step] ?? props.phase}
73
+ selectedId={selectedId}
74
+ onSelect={setSelectedId}
75
+ onConfirm={() => {}}
76
+ onCancel={() => {}}
77
+ />
78
+ );
79
+ }
80
+
81
+ const meta: Meta<typeof Harness> = {
82
+ title: "Mail/RoleAppointmentPrompt",
83
+ component: Harness,
84
+ };
85
+ export default meta;
86
+
87
+ type Story = StoryObj<typeof Harness>;
88
+
89
+ const choosing: PromptPhase = { kind: "choosing" };
90
+
91
+ /** A delete refused because the account has no Trash at all. A first choice. */
92
+ export const None: Story = {
93
+ name: "none",
94
+ args: {
95
+ reason: "none",
96
+ action: { kind: "delete", count: 12 },
97
+ phase: choosing,
98
+ accountEmail: "440737+mvhenten@users.noreply.github.com",
99
+ },
100
+ };
101
+
102
+ /**
103
+ * A delete refused because the folder the user chose is gone. The description
104
+ * names it, so a rename (pick the renamed one) is told from a deletion.
105
+ */
106
+ export const Stale: Story = {
107
+ name: "stale",
108
+ args: {
109
+ reason: "stale",
110
+ action: { kind: "delete", count: 12 },
111
+ staleFolderLabel: "Prullenbak",
112
+ phase: choosing,
113
+ },
114
+ };
115
+
116
+ /**
117
+ * Empty Trash on a folder reader only matched by name. The guess starts
118
+ * selected, so the common case is one tap, and the confirm is the danger
119
+ * variant — this is the only framing whose confirm expunges.
120
+ */
121
+ export const Unconfirmed: Story = {
122
+ name: "unconfirmed",
123
+ args: {
124
+ reason: "unconfirmed",
125
+ action: { kind: "emptyTrash" },
126
+ trashFolderLabel: "Deleted Messages",
127
+ initialSelectedId: "mb-deleted",
128
+ phase: choosing,
129
+ },
130
+ };
131
+
132
+ /**
133
+ * Two writes behind one press, and the story steps through both: the
134
+ * appointment, then the delete it unblocks. Neither has a way out — the write
135
+ * has left, and cancelling a half-applied ceremony is worse than waiting.
136
+ */
137
+ export const Pending: Story = {
138
+ name: "pending",
139
+ args: {
140
+ reason: "none",
141
+ action: { kind: "delete", count: 12 },
142
+ initialSelectedId: "mb-prullenbak",
143
+ phase: { kind: "appointing" },
144
+ phaseCycle: [{ kind: "appointing" }, { kind: "acting" }],
145
+ },
146
+ };
147
+
148
+ /** The write failed. The picker stays, the selection stays, the confirm stays pressable. */
149
+ export const AppointFailed: Story = {
150
+ name: "appoint-failed",
151
+ args: {
152
+ reason: "none",
153
+ action: { kind: "delete", count: 12 },
154
+ initialSelectedId: "mb-prullenbak",
155
+ phase: { kind: "appoint-failed", cause: "generic" },
156
+ },
157
+ };
158
+
159
+ /**
160
+ * The folder was made in the picker and the mail server has not confirmed it
161
+ * yet. A different sentence with a different remedy from a network failure —
162
+ * waiting fixes this one, retrying does not.
163
+ */
164
+ export const AppointRefusedPendingMailbox: Story = {
165
+ name: "appoint-refused-pending-mailbox",
166
+ args: {
167
+ reason: "none",
168
+ action: { kind: "delete", count: 12 },
169
+ initialSelectedId: "mb-prullenbak",
170
+ phase: { kind: "appoint-failed", cause: "mailbox-pending" },
171
+ },
172
+ };