@remit/ui 0.0.60 → 0.0.61

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.60",
3
+ "version": "0.0.61",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -109,16 +109,18 @@ describe("SelectionTopBar", () => {
109
109
  );
110
110
  });
111
111
 
112
- it("offers Something else as an overflow verb, never a glyph", () => {
112
+ it("keeps Junk and Mark read in the overflow menu, never as glyphs", () => {
113
113
  const html = renderToString(
114
114
  createElement(SelectionTopBar, {
115
115
  ...handlers,
116
116
  count: 2,
117
- onSomethingElse: () => undefined,
117
+ onJunk: () => undefined,
118
+ onMarkRead: () => undefined,
118
119
  }),
119
120
  );
120
121
  assert.match(html, /aria-label="More actions"/);
121
- assert.doesNotMatch(row(html, "actions"), /aria-label="Something else"/);
122
+ assert.doesNotMatch(row(html, "actions"), /aria-label="Junk"/);
123
+ assert.doesNotMatch(row(html, "actions"), /aria-label="Mark read"/);
122
124
  });
123
125
 
124
126
  it("replaces the title with the count from the first ticked row", () => {
@@ -150,11 +152,7 @@ describe("SelectionTopBar", () => {
150
152
  count: 3,
151
153
  selectAll,
152
154
  onOrganize: () => undefined,
153
- moveSlot: createElement(
154
- "button",
155
- { type: "button", "aria-label": "Move selected messages" },
156
- "Move",
157
- ),
155
+ onMove: () => undefined,
158
156
  }),
159
157
  );
160
158
 
@@ -1,5 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { FolderInput, Menu, Search, Tag } from "lucide-react";
2
+ import { Menu, Search, Tag } from "lucide-react";
3
3
  import { Button } from "./button.js";
4
4
  import { PopoverMenu } from "./popover-menu.js";
5
5
  import { SearchBar } from "./search-bar.js";
@@ -14,8 +14,8 @@ const meta: Meta<typeof SelectionTopBar> = {
14
14
  onCancel: () => undefined,
15
15
  onMarkRead: () => undefined,
16
16
  onJunk: () => undefined,
17
+ onMove: () => undefined,
17
18
  onOrganize: () => undefined,
18
- onSomethingElse: () => undefined,
19
19
  onDelete: () => undefined,
20
20
  },
21
21
  // Full viewport width — a fixed w-[390px] wrapper inside a 390px Storybook
@@ -30,18 +30,6 @@ export default meta;
30
30
 
31
31
  type Story = StoryObj<typeof SelectionTopBar>;
32
32
 
33
- /** Stand-in for the caller's move-to-folder trigger, which owns its own
34
- * folder-picker data. The bar only reserves the slot. */
35
- const MoveSlot = () => (
36
- <button
37
- type="button"
38
- aria-label="Move selected messages"
39
- className="inline-flex size-11 shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-raised"
40
- >
41
- <FolderInput className="size-5" />
42
- </button>
43
- );
44
-
45
33
  /** Stand-in for the caller's apply-label picker, whose list belongs to the
46
34
  * account. A worded row, so it reads as one of the menu's actions. */
47
35
  const LabelSlot = () => (
@@ -147,16 +135,17 @@ export const IdleSearching: Story = {
147
135
  /**
148
136
  * One ticked row. The count takes the title's place and the header's own
149
137
  * chrome stands down; the verbs arrive with it: Delete, Move and Organize
150
- * carry a glyph, Junk, Mark read and Something else live under the kebab.
151
- * Below 768px select-all takes a second row, so row one stays a count and a
138
+ * carry a glyph, Junk and Mark read live under the kebab. Every one of them
139
+ * opens the wizard. Below 768px select-all takes a second row, so row one
140
+ * stays a count and a
152
141
  * row of verbs with a back arrow out of selection.
153
142
  */
154
143
  export const One: Story = {
155
- args: { ...chrome, count: 1, selectAll, moveSlot: <MoveSlot /> },
144
+ args: { ...chrome, count: 1, selectAll },
156
145
  };
157
146
 
158
147
  export const Many: Story = {
159
- args: { count: 3, selectAll, moveSlot: <MoveSlot /> },
148
+ args: { count: 3, selectAll },
160
149
  };
161
150
 
162
151
  /**
@@ -167,7 +156,6 @@ export const WithLabelPicker: Story = {
167
156
  args: {
168
157
  count: 3,
169
158
  selectAll,
170
- moveSlot: <MoveSlot />,
171
159
  overflowSlot: <LabelSlot />,
172
160
  },
173
161
  };
@@ -175,13 +163,13 @@ export const WithLabelPicker: Story = {
175
163
  /** A selection spanning folders or accounts has no move target and no
176
164
  * account to file a rule under: the bar carries Delete and the overflow. */
177
165
  export const WithoutMoveOrOrganize: Story = {
178
- args: { count: 2, onOrganize: undefined, selectAll },
166
+ args: { count: 2, onMove: undefined, onOrganize: undefined, selectAll },
179
167
  };
180
168
 
181
169
  /** A mutation in flight: Delete carries the spinner and every other verb
182
170
  * stands down, because nothing else can act until it lands. */
183
171
  export const Busy: Story = {
184
- args: { count: 2, isBusy: true, moveSlot: <MoveSlot />, selectAll },
172
+ args: { count: 2, isBusy: true, selectAll },
185
173
  };
186
174
 
187
175
  export const CrossAccountHint: Story = {
@@ -204,7 +192,6 @@ export const CrossAccountHint: Story = {
204
192
  export const AllSelected: Story = {
205
193
  args: {
206
194
  count: 47,
207
- moveSlot: <MoveSlot />,
208
195
  selectAll: {
209
196
  checked: true,
210
197
  indeterminate: false,
@@ -260,7 +247,6 @@ export const Escalated: Story = {
260
247
  args: {
261
248
  count: 3412,
262
249
  statusLabel: 'All 3,412 matching "npm" selected',
263
- moveSlot: <MoveSlot />,
264
250
  notice: {
265
251
  tone: "info",
266
252
  text: "",
@@ -367,7 +353,6 @@ export const MarkingReadWithProgress: Story = {
367
353
  export const PartialFailureMove: Story = {
368
354
  args: {
369
355
  count: 340,
370
- moveSlot: <MoveSlot />,
371
356
  notice: {
372
357
  tone: "danger",
373
358
  text: "3,072 moved. 340 couldn't be moved.",
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  ArrowLeft,
3
+ FolderInput,
3
4
  Loader2,
4
5
  MailOpen,
5
6
  ShieldAlert,
6
7
  Sparkles,
7
8
  Trash2,
8
- Wand2,
9
9
  } from "lucide-react";
10
10
  import type { ReactNode } from "react";
11
11
  import { Banner, type BannerTone } from "./banner.js";
@@ -40,6 +40,17 @@ export interface SelectionTopBarProps {
40
40
  /** Leaves selection, from the back arrow at the head of the bar. */
41
41
  onCancel: () => void;
42
42
  onDelete: () => void;
43
+ /**
44
+ * Opens the wizard on Move. Omitted where a destination cannot be resolved —
45
+ * a selection spanning accounts, or a surface with no owning mailbox.
46
+ */
47
+ onMove?: () => void;
48
+ /**
49
+ * The Move verb for a selection the wizard cannot take: an escalated
50
+ * predicate, which no bounded list of ids stands in for, so it keeps the
51
+ * caller's own folder picker. Rendered only in `onMove`'s place.
52
+ */
53
+ moveSlot?: ReactNode;
43
54
  /** Opens Organize for the selection. Omitted where organize cannot be
44
55
  * scoped to one account. */
45
56
  onOrganize?: () => void;
@@ -48,12 +59,6 @@ export interface SelectionTopBarProps {
48
59
  onJunk?: () => void;
49
60
  /** Optional — dropped from the overflow menu while `isBusy`. */
50
61
  onMarkRead?: () => void;
51
- /**
52
- * Opens the free-text organize panel. An overflow verb rather than a glyph:
53
- * it collapses a verb, a destination and a scope into one tap, which is the
54
- * opposite of what the bar's own verbs do.
55
- */
56
- onSomethingElse?: () => void;
57
62
  /**
58
63
  * The list header's own chrome, rendered only while nothing is ticked: the
59
64
  * nav button, the unread count beside the title, and the search affordance.
@@ -74,11 +79,6 @@ export interface SelectionTopBarProps {
74
79
  * bar over, so the second way into the same wizard stands down.
75
80
  */
76
81
  idleSlot?: ReactNode;
77
- /**
78
- * The Move verb. A render prop because the trigger owns the folder picker
79
- * and its API dependencies; the bar only gives it a place in the verb row.
80
- */
81
- moveSlot?: ReactNode;
82
82
  /**
83
83
  * Extra rows at the foot of the overflow menu — the apply-label picker,
84
84
  * whose list belongs to the account rather than to the bar. Withdrawn while
@@ -171,16 +171,16 @@ export function SelectionTopBar({
171
171
  count,
172
172
  onCancel,
173
173
  onDelete,
174
+ onMove,
175
+ moveSlot,
174
176
  onOrganize,
175
177
  onJunk,
176
178
  onMarkRead,
177
- onSomethingElse,
178
179
  navSlot,
179
180
  titleMeta,
180
181
  searchSlot,
181
182
  searchField,
182
183
  idleSlot,
183
- moveSlot,
184
184
  overflowSlot,
185
185
  isBusy = false,
186
186
  isCounting = false,
@@ -211,15 +211,6 @@ export function SelectionTopBar({
211
211
  onSelect: onMarkRead,
212
212
  });
213
213
  }
214
- if (onSomethingElse && !isBusy && !isCounting) {
215
- overflowItems.push({
216
- key: "something-else",
217
- label: "Something else",
218
- icon: <Wand2 className="size-4" />,
219
- onSelect: onSomethingElse,
220
- });
221
- }
222
-
223
214
  return (
224
215
  <header
225
216
  data-selection-bar=""
@@ -287,7 +278,19 @@ export function SelectionTopBar({
287
278
  aria-busy={isBusy || undefined}
288
279
  className="shrink-0"
289
280
  />
290
- {!isBusy && moveSlot}
281
+ {!isBusy &&
282
+ (onMove ? (
283
+ <Button
284
+ variant="ghost"
285
+ size="touch"
286
+ icon={<FolderInput className="size-5" />}
287
+ onClick={onMove}
288
+ aria-label="Move selected messages"
289
+ className="shrink-0"
290
+ />
291
+ ) : (
292
+ moveSlot
293
+ ))}
291
294
  {!isBusy && onOrganize && (
292
295
  <Button
293
296
  variant="ghost"
@@ -581,7 +581,7 @@ describe("SelectionWizard", () => {
581
581
  wizardProps({ verb: "organize", steps, step: "properties" }),
582
582
  ),
583
583
  );
584
- assert.match(text(html), /Step 1 of 4 · Apply to/);
584
+ assert.match(text(html), /Step 1 of 5 · Apply to/);
585
585
  assert.match(html, /What should this apply to\?/);
586
586
  assert.match(html, /These 2 messages/);
587
587
  assert.doesNotMatch(html, /Match properties/);
@@ -657,23 +657,41 @@ describe("SelectionWizard", () => {
657
657
  }),
658
658
  ),
659
659
  );
660
- assert.match(html, /Add a property to match on\./);
661
- assert.match(html, /aria-disabled="true"/);
660
+ assert.match(text(html), /Add a property to match on\./);
661
+ assert.match(html, /opacity-55/);
662
+ // Nothing disables (#477 1.7), and `aria-disabled` disables it for
663
+ // everyone driving the page by anything other than a mouse.
664
+ assert.doesNotMatch(html, /aria-disabled/);
662
665
  assert.doesNotMatch(html, /<button[^>]*disabled=""/);
663
666
  });
664
667
 
665
- it("holds the blocked reason back until Continue is pressed", () => {
666
- const html = renderToString(
668
+ it("carries the blocked reason on the control before it is pressed, and shows it after", () => {
669
+ const quiet = renderToString(
670
+ createElement(
671
+ SelectionWizard,
672
+ wizardProps({
673
+ step: "properties",
674
+ blockedReason: "Add a property to match on.",
675
+ }),
676
+ ),
677
+ );
678
+ // Described, so it is heard on the control; not on screen until pressed.
679
+ assert.match(quiet, /aria-describedby="/);
680
+ assert.match(quiet, /sr-only/);
681
+ assert.doesNotMatch(quiet, /role="status"/);
682
+
683
+ const nudged = renderToString(
667
684
  createElement(
668
685
  SelectionWizard,
669
686
  wizardProps({
670
687
  step: "properties",
671
688
  blockedReason: "Add a property to match on.",
689
+ nudged: true,
672
690
  }),
673
691
  ),
674
692
  );
675
- assert.doesNotMatch(html, /Add a property to match on\./);
676
- assert.match(html, /aria-disabled="true"/);
693
+ assert.match(nudged, /role="status"/);
694
+ assert.doesNotMatch(nudged, /sr-only/);
677
695
  });
678
696
 
679
697
  it("commits under the scope's own label, in the verb's own tone", () => {
@@ -28,6 +28,7 @@ import {
28
28
  matchPhrase,
29
29
  matchSummary,
30
30
  type RunCopy,
31
+ type RunOutcome,
31
32
  type RunState,
32
33
  runCopy,
33
34
  type SampleEmptyReason,
@@ -133,7 +134,16 @@ export function WizardScreen({
133
134
  }: WizardScreenProps) {
134
135
  const active = stepIndex(steps, step);
135
136
  return (
136
- <div className="fixed inset-0 z-50 flex flex-col font-sans text-fg md:items-center md:justify-center md:bg-black/40 md:p-6">
137
+ // A modal at every width: full-bleed below 768px it covers the list rather
138
+ // than sitting beside it, so the list behind must leave the accessibility
139
+ // tree with it — otherwise the verbs that opened the wizard are still
140
+ // reachable underneath the screen that replaced them.
141
+ <div
142
+ role="dialog"
143
+ aria-modal="true"
144
+ aria-label={title}
145
+ className="fixed inset-0 z-50 flex flex-col font-sans text-fg md:items-center md:justify-center md:bg-black/40 md:p-6"
146
+ >
137
147
  <div className="flex min-h-0 w-full flex-1 flex-col bg-canvas md:h-[45rem] md:max-h-[calc(100dvh-3rem)] md:w-[35rem] md:max-w-[calc(100vw-3rem)] md:flex-none md:overflow-hidden md:rounded-xl md:border md:border-line md:shadow-lg">
138
148
  <header className="shrink-0 border-b border-line px-3 pb-2 pt-3">
139
149
  <div className="flex items-center gap-1">
@@ -190,6 +200,12 @@ export interface ChoiceCardProps {
190
200
  children?: ReactNode;
191
201
  }
192
202
 
203
+ /**
204
+ * Nothing disables (#477 1.7). An unavailable choice is dimmed and stays
205
+ * pressable, because pressing it is what takes the user somewhere that works —
206
+ * `aria-disabled` would announce it as a control that does nothing and take that
207
+ * away. What makes it unavailable is announced with it instead.
208
+ */
193
209
  export function ChoiceCard({
194
210
  selected,
195
211
  title,
@@ -198,13 +214,14 @@ export function ChoiceCard({
198
214
  onSelect,
199
215
  children,
200
216
  }: ChoiceCardProps) {
217
+ const hintId = useId();
201
218
  return (
202
219
  <div className="space-y-1.5">
203
220
  <button
204
221
  type="button"
205
222
  onClick={onSelect}
206
223
  aria-pressed={selected}
207
- aria-disabled={unavailable || undefined}
224
+ aria-describedby={unavailable && children ? hintId : undefined}
208
225
  className={cn(
209
226
  "flex w-full items-start gap-3 rounded-lg border p-3 text-left transition-colors",
210
227
  selected
@@ -228,7 +245,7 @@ export function ChoiceCard({
228
245
  </span>
229
246
  </span>
230
247
  </button>
231
- {children}
248
+ {children && <div id={hintId}>{children}</div>}
232
249
  </div>
233
250
  );
234
251
  }
@@ -244,6 +261,12 @@ export interface SelectionSampleProps {
244
261
  label: string;
245
262
  /** Why there are no rows. Defaults to nothing matching, never to a bare empty state. */
246
263
  emptyReason?: SampleEmptyReason;
264
+ /**
265
+ * The rows are still being fetched. No rows yet is not the same answer as no
266
+ * rows at all, and "nothing matches" beside a server count that says otherwise
267
+ * is the wrong conclusion (#477 3.5).
268
+ */
269
+ loading?: boolean;
247
270
  }
248
271
 
249
272
  const sampleFooter = (count: MatchCount, shown: number): string => {
@@ -266,6 +289,7 @@ export function SelectionSample({
266
289
  count,
267
290
  label,
268
291
  emptyReason,
292
+ loading,
269
293
  }: SelectionSampleProps) {
270
294
  return (
271
295
  <section className="flex flex-col rounded-lg border border-line bg-surface">
@@ -274,7 +298,9 @@ export function SelectionSample({
274
298
  </h2>
275
299
  {messages.length === 0 ? (
276
300
  <p className="px-3 py-4 text-xs text-fg-muted">
277
- {sampleEmptyCopy(emptyReason ?? "noMatch")}
301
+ {loading
302
+ ? "Fetching the messages this covers…"
303
+ : sampleEmptyCopy(emptyReason ?? "noMatch")}
278
304
  </p>
279
305
  ) : (
280
306
  <>
@@ -316,6 +342,13 @@ export interface FooterNavProps {
316
342
  nudged?: boolean;
317
343
  }
318
344
 
345
+ /**
346
+ * Nothing disables (#477 1.7). A Continue with an answer still missing is dimmed
347
+ * and stays pressable — and stays pressable to assistive technology too, which
348
+ * `aria-disabled` would have taken away along with the reason. The reason is on
349
+ * the control the whole time it applies, through `aria-describedby`; pressing it
350
+ * is what brings the reason on screen.
351
+ */
319
352
  export function FooterNav({
320
353
  backLabel = "Back",
321
354
  onBack,
@@ -325,10 +358,15 @@ export function FooterNav({
325
358
  blockedReason,
326
359
  nudged,
327
360
  }: FooterNavProps) {
361
+ const reasonId = useId();
328
362
  return (
329
363
  <div className="space-y-2">
330
- {nudged && blockedReason && (
331
- <p role="status" className="px-1 text-2xs text-warning">
364
+ {blockedReason && (
365
+ <p
366
+ id={reasonId}
367
+ role={nudged ? "status" : undefined}
368
+ className={cn("px-1 text-2xs text-warning", !nudged && "sr-only")}
369
+ >
332
370
  {blockedReason}
333
371
  </p>
334
372
  )}
@@ -346,7 +384,7 @@ export function FooterNav({
346
384
  variant={nextVariant}
347
385
  size="touch"
348
386
  onClick={onNext}
349
- aria-disabled={blockedReason ? true : undefined}
387
+ aria-describedby={blockedReason ? reasonId : undefined}
350
388
  className={cn("flex-1", blockedReason && "opacity-55")}
351
389
  >
352
390
  {nextLabel}
@@ -366,6 +404,12 @@ export interface MatchStepProps {
366
404
  * of the deployment: the door stays pressable and dimmed.
367
405
  */
368
406
  semanticUnavailable?: boolean;
407
+ /**
408
+ * What the mail server said when the widen was asked to run and failed. A
409
+ * failure is a reason the door cannot run, so it dims the door like any other
410
+ * — with the server's own words under it rather than a generic line.
411
+ */
412
+ semanticErrorDetail?: string;
369
413
  /** The dimmed door was pressed and the senders were filled in instead. */
370
414
  semanticFallbackTaken?: boolean;
371
415
  onSemanticFallback: () => void;
@@ -377,6 +421,7 @@ export function MatchStepBody({
377
421
  mode,
378
422
  onModeChange,
379
423
  semanticUnavailable,
424
+ semanticErrorDetail,
380
425
  semanticFallbackTaken,
381
426
  onSemanticFallback,
382
427
  sample,
@@ -401,6 +446,11 @@ export function MatchStepBody({
401
446
  title={matchDoorLabel("similar", selectedCount)}
402
447
  description={matchDoorHint("similar")}
403
448
  >
449
+ {semanticErrorDetail && (
450
+ <p role="status" className="px-1 text-2xs text-danger">
451
+ Couldn't find similar messages: {semanticErrorDetail}
452
+ </p>
453
+ )}
404
454
  {semanticFallbackTaken && (
405
455
  <p role="status" className="px-1 text-2xs text-fg-subtle">
406
456
  Similar-mail matching is unavailable right now — matching on the
@@ -568,6 +618,12 @@ export interface FolderStepProps {
568
618
  name: string,
569
619
  signal?: AbortSignal,
570
620
  ) => Promise<MoveMailboxOption>;
621
+ /**
622
+ * Why there is no folder list to choose from — a selection spanning accounts
623
+ * has no single account whose folders these could be (#477 5.5). Said here
624
+ * rather than left as an empty picker.
625
+ */
626
+ restriction?: string;
571
627
  }
572
628
 
573
629
  export function FolderStepBody({
@@ -575,14 +631,21 @@ export function FolderStepBody({
575
631
  mailboxId,
576
632
  onSelect,
577
633
  onCreateFolder,
634
+ restriction,
578
635
  }: FolderStepProps) {
579
636
  const chosen = mailboxes.find((mailbox) => mailbox.id === mailboxId);
580
637
  return (
581
638
  <div className="flex min-h-0 flex-col gap-3">
582
- <p className="px-1 text-xs text-fg-muted">
583
- {chosen
584
- ? `Moving to ${chosen.label}.`
585
- : "Search for a folder, or type a name that doesn't exist yet to make one."}
639
+ <p
640
+ className={cn(
641
+ "px-1 text-xs",
642
+ restriction ? "text-warning" : "text-fg-muted",
643
+ )}
644
+ >
645
+ {restriction ??
646
+ (chosen
647
+ ? `Moving to ${chosen.label}.`
648
+ : "Search for a folder, or type a name that doesn't exist yet to make one.")}
586
649
  </p>
587
650
  <div className="overflow-hidden rounded-lg border border-line bg-surface">
588
651
  <MoveMailboxPicker
@@ -611,16 +674,26 @@ export interface RuleStepProps {
611
674
  draft: WizardDraft;
612
675
  onScopeChange: (scope: RuleScope) => void;
613
676
  onUntilChange: (until: string) => void;
677
+ /**
678
+ * Why the two persisting scopes cannot be reached from here — a selection
679
+ * spanning accounts has no single account to create the rule for (#477 5.5).
680
+ * They stay pressable and say this; the one-off scope is unaffected.
681
+ */
682
+ restriction?: string;
614
683
  }
615
684
 
616
685
  export function RuleStepBody({
617
686
  draft,
618
687
  onScopeChange,
619
688
  onUntilChange,
689
+ restriction,
620
690
  }: RuleStepProps) {
621
691
  const untilId = useId();
622
692
  const { scope, until = "" } = draft;
623
693
  const unreadable = unreadableDraftClauses(draft).length > 0;
694
+ const restricted = restriction && (
695
+ <p className="px-1 text-2xs text-warning">{restriction}</p>
696
+ );
624
697
 
625
698
  return (
626
699
  <div className="space-y-2">
@@ -638,16 +711,21 @@ export function RuleStepBody({
638
711
  </ChoiceCard>
639
712
  <ChoiceCard
640
713
  selected={scope === "standing"}
714
+ unavailable={Boolean(restriction)}
641
715
  onSelect={() => onScopeChange("standing")}
642
716
  title={scopeLabel("standing")}
643
717
  description="Saves a rule and applies it to new mail as it arrives, and to the mail already in your mailbox. You'll name it next."
644
- />
718
+ >
719
+ {restricted}
720
+ </ChoiceCard>
645
721
  <ChoiceCard
646
722
  selected={scope === "until"}
723
+ unavailable={Boolean(restriction)}
647
724
  onSelect={() => onScopeChange("until")}
648
725
  title={scopeLabel("until")}
649
726
  description="The same rule, and it stops on a day you pick."
650
727
  >
728
+ {restricted}
651
729
  {scope === "until" && (
652
730
  <div className="rounded-lg border border-line bg-surface p-2">
653
731
  <FieldLabel htmlFor={untilId}>Stops on</FieldLabel>
@@ -753,7 +831,7 @@ export function ReviewStepBody({
753
831
  <p className="text-sm text-fg">
754
832
  <span className="font-semibold">{label}</span>{" "}
755
833
  {matchPhrase(description)}
756
- {verb === "move" && folder ? ` to ${folder}` : ""}
834
+ {folder ? ` to ${folder}` : ""}
757
835
  {persists && (
758
836
  <>
759
837
  {" "}
@@ -818,10 +896,25 @@ export interface RunStepProps {
818
896
  applied: number;
819
897
  /** The messages the mail server rejected, named one by one. */
820
898
  failures: readonly WizardMessage[];
899
+ /**
900
+ * How many the mail server rejected, when that is more than can be named. A
901
+ * server-side pass reports its own failure count without handing back the
902
+ * messages behind it; defaults to the ones named here.
903
+ */
904
+ failedCount?: number;
821
905
  onRetry: () => void;
822
906
  onDismiss: () => void;
823
907
  }
824
908
 
909
+ const runOutcomeOf = (props: RunStepProps): RunOutcome => ({
910
+ state: props.state,
911
+ verb: props.verb,
912
+ scope: props.scope,
913
+ matched: props.matched,
914
+ applied: props.applied,
915
+ failed: props.failedCount ?? props.failures.length,
916
+ });
917
+
825
918
  const runIcon = (tone: RunCopy["tone"]): ReactNode => {
826
919
  if (tone === "progress") {
827
920
  return <Loader2 className="size-10 animate-spin text-accent" />;
@@ -837,22 +930,10 @@ const runIcon = (tone: RunCopy["tone"]): ReactNode => {
837
930
  );
838
931
  };
839
932
 
840
- export function RunStepBody({
841
- state,
842
- verb,
843
- scope,
844
- matched,
845
- applied,
846
- failures,
847
- }: RunStepProps) {
848
- const copy = runCopy({
849
- state,
850
- verb,
851
- scope,
852
- matched,
853
- applied,
854
- failed: failures.length,
855
- });
933
+ export function RunStepBody(props: RunStepProps) {
934
+ const { matched, applied, failures } = props;
935
+ const outcome = runOutcomeOf(props);
936
+ const copy = runCopy(outcome);
856
937
 
857
938
  return (
858
939
  <div className="space-y-4 pt-2">
@@ -866,7 +947,7 @@ export function RunStepBody({
866
947
  <ProgressBar
867
948
  value={applied}
868
949
  max={matched}
869
- tone={failures.length > 0 ? "warning" : "success"}
950
+ tone={outcome.failed > 0 ? "warning" : "success"}
870
951
  />
871
952
  )}
872
953
 
@@ -894,24 +975,9 @@ export function RunStepBody({
894
975
  );
895
976
  }
896
977
 
897
- export function RunFooter({
898
- state,
899
- verb,
900
- scope,
901
- matched,
902
- applied,
903
- failures,
904
- onRetry,
905
- onDismiss,
906
- }: RunStepProps) {
907
- const copy = runCopy({
908
- state,
909
- verb,
910
- scope,
911
- matched,
912
- applied,
913
- failed: failures.length,
914
- });
978
+ export function RunFooter(props: RunStepProps) {
979
+ const { onRetry, onDismiss } = props;
980
+ const copy = runCopy(runOutcomeOf(props));
915
981
 
916
982
  if (copy.retryLabel === undefined) {
917
983
  return (
@@ -1036,12 +1102,17 @@ export function SelectionWizard(props: SelectionWizardProps) {
1036
1102
  if (step === "run") return <RunFooter {...stepProps(props.run, step)} />;
1037
1103
  if (step === "review") {
1038
1104
  const review = stepProps(props.review, step);
1105
+ // The commit is a Continue like any other: a match that reaches nothing,
1106
+ // or a count still moving, dims it and says so rather than committing a
1107
+ // bulk action over nothing and reporting it as done.
1039
1108
  return (
1040
1109
  <FooterNav
1041
1110
  onBack={onBack}
1042
1111
  nextLabel={review.scope ? commitLabel(review.scope) : label}
1043
1112
  nextVariant={destructive ? "danger" : "primary"}
1044
1113
  onNext={onCommit}
1114
+ blockedReason={props.blockedReason}
1115
+ nudged={props.nudged}
1045
1116
  />
1046
1117
  );
1047
1118
  }
@@ -1058,8 +1129,7 @@ export function SelectionWizard(props: SelectionWizardProps) {
1058
1129
 
1059
1130
  const title = (): string => {
1060
1131
  if (step !== "run") return screen.title ?? label;
1061
- const run = stepProps(props.run, step);
1062
- return runCopy({ ...run, failed: run.failures.length }).screenTitle;
1132
+ return runCopy(runOutcomeOf(stepProps(props.run, step))).screenTitle;
1063
1133
  };
1064
1134
 
1065
1135
  return (