@remit/ui 0.0.137 → 0.0.139

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,5 +1,6 @@
1
1
  import { Check, Folder } from "lucide-react";
2
- import { useState } from "react";
2
+ import { useId, useRef, useState } from "react";
3
+ import { Banner } from "./banner.js";
3
4
  import { Button } from "./button.js";
4
5
  import {
5
6
  canonicalRoleLabel,
@@ -38,20 +39,56 @@ export interface CandidateFolder {
38
39
  messageCount: number;
39
40
  }
40
41
 
42
+ /** Where a role's answer came from (#887). */
43
+ export type RoleAppointmentSource =
44
+ | "Appointed"
45
+ | "Flagged"
46
+ | "Reserved"
47
+ | "Proposed"
48
+ | "Stale"
49
+ | "None";
50
+
51
+ export interface RoleAppointment {
52
+ /** The mailbox the role resolves to, or null when it resolves to none. */
53
+ mailboxId: string | null;
54
+ source: RoleAppointmentSource;
55
+ /** `Stale` only: the path the folder the user chose last had. */
56
+ staleFolderPath?: string;
57
+ }
58
+
41
59
  /** Empty <option> value standing for "no folder appointed". */
42
60
  const NONE = "";
43
61
 
62
+ const UNRESOLVED: RoleAppointment = { mailboxId: null, source: "None" };
63
+
44
64
  /** Picker option text: `Concepten · 340 msgs`. */
45
65
  function folderOptionLabel(folder: CandidateFolder): string {
46
66
  const noun = folder.messageCount === 1 ? "msg" : "msgs";
47
67
  return `${providerLeaf(folder.providerPath)} · ${folder.messageCount} ${noun}`;
48
68
  }
49
69
 
70
+ const messages = (count: number): string =>
71
+ `${count} ${count === 1 ? "message" : "messages"}`;
72
+
73
+ /**
74
+ * The provenance clause in front of the path and count. Read the source rather
75
+ * than inferring it from the shape of the row: only `Appointed` means a person
76
+ * decided, and `Proposed` is a name match nobody confirmed.
77
+ */
78
+ const provenanceClause = (
79
+ source: RoleAppointmentSource,
80
+ roleLabel: string,
81
+ ): string => {
82
+ if (source === "Appointed") return "Chosen by you";
83
+ if (source === "Flagged") return `The mail server marks it as ${roleLabel}`;
84
+ if (source === "Reserved") return "The account's own INBOX";
85
+ return "Matched by name, not confirmed";
86
+ };
87
+
50
88
  interface RoleAppointmentRowProps {
51
89
  role: FolderRole;
52
90
  folders: readonly CandidateFolder[];
53
- /** The mailbox appointed to this role, or null when the role is unfilled. */
54
- appointedId: string | null;
91
+ appointment: RoleAppointment;
55
92
  /** Committed display-name override for the appointed folder. */
56
93
  displayName: string;
57
94
  onAppoint: (role: FolderRole, mailboxId: string | null) => void;
@@ -64,21 +101,62 @@ interface RoleAppointmentRowProps {
64
101
  * folder that actually holds mail), and — once a folder is appointed — a rename
65
102
  * field for its sidebar label. Selecting a folder here clears it from any other
66
103
  * role on write; the picker can never produce a duplicate.
104
+ *
105
+ * The line under the control says where the row's answer came from, and is
106
+ * pointed at by the Select's `aria-describedby` so the provenance is announced
107
+ * with the control that changes it.
67
108
  */
68
109
  function RoleAppointmentRow({
69
110
  role,
70
111
  folders,
71
- appointedId,
112
+ appointment,
72
113
  displayName,
73
114
  onAppoint,
74
115
  onRename,
75
116
  }: RoleAppointmentRowProps) {
76
117
  const [draftName, setDraftName] = useState(displayName);
77
- const appointed = folders.find((f) => f.mailboxId === appointedId) ?? null;
118
+ const selectRef = useRef<HTMLSelectElement>(null);
119
+ const subtitleId = useId();
120
+ const { mailboxId, source, staleFolderPath } = appointment;
121
+ const appointed = folders.find((f) => f.mailboxId === mailboxId) ?? null;
78
122
  const label = canonicalRoleLabel(role);
79
123
  const renameDirty =
80
124
  appointed != null && draftName.trim() !== displayName.trim();
81
125
 
126
+ const subtitle = (() => {
127
+ if (source === "None") {
128
+ const needsOne = role === "trash" ? " Deleting mail needs one." : "";
129
+ return (
130
+ <span id={subtitleId} className="pl-[7.5rem] text-xs text-fg-muted">
131
+ {`Not set — pick the folder this account uses for ${label}.${needsOne}`}
132
+ </span>
133
+ );
134
+ }
135
+ if (source === "Stale" || !appointed) return null;
136
+ return (
137
+ <span
138
+ id={subtitleId}
139
+ className="truncate pl-[7.5rem] text-2xs text-fg-subtle"
140
+ title={appointed.providerPath}
141
+ >
142
+ {`${provenanceClause(source, label)} · ${appointed.providerPath} · ${messages(appointed.messageCount)}`}
143
+ </span>
144
+ );
145
+ })();
146
+
147
+ // Trash is the one role where a broken appointment stops a verb; the others
148
+ // fall back to a live folder. With no fallback to name, the sentence stops
149
+ // rather than inventing one.
150
+ const staleFallback =
151
+ role === "trash"
152
+ ? " Deleting mail is stopped until you pick another one."
153
+ : appointed
154
+ ? ` reader is using ${providerLeaf(appointed.providerPath)} instead.`
155
+ : "";
156
+ const staleNotice = `The folder you chose for ${label}${
157
+ staleFolderPath ? ` — ${staleFolderPath} —` : ""
158
+ } is gone from the mail server.${staleFallback}`;
159
+
82
160
  return (
83
161
  <div className="flex flex-col gap-1 border-b border-line px-row-inset py-2.5 last:border-b-0">
84
162
  <div className="flex items-center gap-2">
@@ -87,9 +165,15 @@ function RoleAppointmentRow({
87
165
  {label}
88
166
  </span>
89
167
  <Select
168
+ ref={selectRef}
90
169
  className="w-56 shrink-0"
91
- value={appointedId ?? NONE}
170
+ value={mailboxId ?? NONE}
92
171
  aria-label={`Folder for ${label}`}
172
+ // Whichever line the row renders — the provenance subtitle or the
173
+ // stale callout — is the one that explains this control.
174
+ aria-describedby={
175
+ subtitle || source === "Stale" ? subtitleId : undefined
176
+ }
93
177
  onChange={(event) =>
94
178
  onAppoint(
95
179
  role,
@@ -126,14 +210,23 @@ function RoleAppointmentRow({
126
210
  </>
127
211
  )}
128
212
  </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>
213
+ {subtitle}
214
+ {source === "Stale" && (
215
+ <Banner tone="warning" variant="soft" className="ml-[7.5rem]">
216
+ <span className="flex flex-wrap items-center gap-2">
217
+ <span id={subtitleId} className="flex-1">
218
+ {staleNotice}
219
+ </span>
220
+ <Button
221
+ variant="secondary"
222
+ size="sm"
223
+ className="shrink-0"
224
+ onClick={() => selectRef.current?.focus()}
225
+ >
226
+ Pick a folder
227
+ </Button>
228
+ </span>
229
+ </Banner>
137
230
  )}
138
231
  </div>
139
232
  );
@@ -143,8 +236,8 @@ export interface RoleAppointmentListProps {
143
236
  accountEmail: string;
144
237
  /** Every folder the account exposes (candidates for any role). */
145
238
  folders: readonly CandidateFolder[];
146
- /** role → appointed mailboxId. A missing role means "None". */
147
- appointments: Readonly<Record<string, string | null>>;
239
+ /** role → what fills it and where that answer came from. */
240
+ appointments: Readonly<Record<string, RoleAppointment>>;
148
241
  /** mailboxId → committed display-name override. */
149
242
  displayNames?: Readonly<Record<string, string>>;
150
243
  onAppoint: (role: FolderRole, mailboxId: string | null) => void;
@@ -167,7 +260,9 @@ export function RoleAppointmentList({
167
260
  onRename,
168
261
  }: RoleAppointmentListProps) {
169
262
  const appointedIds = new Set(
170
- Object.values(appointments).filter((id): id is string => id != null),
263
+ Object.values(appointments)
264
+ .map((appointment) => appointment.mailboxId)
265
+ .filter((id): id is string => id != null),
171
266
  );
172
267
  const leftovers = folders.filter((f) => !appointedIds.has(f.mailboxId));
173
268
 
@@ -180,19 +275,25 @@ export function RoleAppointmentList({
180
275
  <p className="text-xs text-fg-muted">
181
276
  Each role points to one folder. Pick the folder that holds the mail —
182
277
  the counts help you tell real folders from empty look-alikes.
183
- Appointing a folder here removes it from any other role.
278
+ Appointing a folder here removes it from any other role. Each row says
279
+ where its answer came from — your choice, the mail server's own flag,
280
+ or a name reader matched.
184
281
  </p>
185
282
  </header>
186
283
  <div className="rounded-sm border border-line bg-surface">
187
284
  {APPOINTABLE_ROLES.map((role) => {
188
- const appointedId = appointments[role] ?? null;
285
+ const appointment = appointments[role] ?? UNRESOLVED;
189
286
  return (
190
287
  <RoleAppointmentRow
191
288
  key={role}
192
289
  role={role}
193
290
  folders={folders}
194
- appointedId={appointedId}
195
- displayName={appointedId ? (displayNames[appointedId] ?? "") : ""}
291
+ appointment={appointment}
292
+ displayName={
293
+ appointment.mailboxId
294
+ ? (displayNames[appointment.mailboxId] ?? "")
295
+ : ""
296
+ }
196
297
  onAppoint={onAppoint}
197
298
  onRename={onRename}
198
299
  />
@@ -214,8 +315,7 @@ export function RoleAppointmentList({
214
315
  {providerLeaf(folder.providerPath)}
215
316
  </span>
216
317
  <span className="ml-auto shrink-0 text-2xs text-fg-subtle">
217
- {folder.messageCount}{" "}
218
- {folder.messageCount === 1 ? "message" : "messages"}
318
+ {messages(folder.messageCount)}
219
319
  </span>
220
320
  </li>
221
321
  ))}
@@ -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
+ };