@remit/web-client 0.0.321 → 0.0.323

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.
Files changed (190) hide show
  1. package/package.json +4 -4
  2. package/src/auth/SignInAgain.tsx +18 -0
  3. package/src/auth/amplify-config.test.ts +1 -107
  4. package/src/auth/auth-interceptor.test.ts +0 -6
  5. package/src/auth/cognito/cognito-token.test.ts +2 -4
  6. package/src/components/calendar/AgendaStrip.tsx +1 -1
  7. package/src/components/calendar/CalendarWorkspace.tsx +1 -1
  8. package/src/components/calendar/ChangeCalendarSuggestion.tsx +1 -1
  9. package/src/components/calendar/OpenCalendarEvent.tsx +1 -18
  10. package/src/components/compose/ComposeForm.tsx +11 -6
  11. package/src/components/compose/FullCompose.tsx +1 -1
  12. package/src/components/compose/MobileComposeSheet.tsx +1 -2
  13. package/src/components/compose/compose.stories.tsx +18 -0
  14. package/src/components/compose/draft-discard-failure.render.test.ts +138 -0
  15. package/src/components/config-import/config-import.fixtures.ts +8 -9
  16. package/src/components/config-import/config-import.stories.tsx +2 -2
  17. package/src/components/layout/MailTopBar.tsx +3 -0
  18. package/src/components/mail/BriefPane.tsx +0 -3
  19. package/src/components/mail/ConversationView.tsx +1 -3
  20. package/src/components/mail/DailyBrief.tsx +5 -28
  21. package/src/components/mail/FlaggedList.tsx +3 -4
  22. package/src/components/mail/FlaggedPane.tsx +0 -3
  23. package/src/components/mail/IntelligenceDrawer.tsx +0 -3
  24. package/src/components/mail/IntelligencePane.render.test.ts +0 -1
  25. package/src/components/mail/IntelligencePane.test.ts +1 -95
  26. package/src/components/mail/IntelligencePane.tsx +3 -93
  27. package/src/components/mail/MailListHeader.tsx +12 -43
  28. package/src/components/mail/MailNav.tsx +6 -2
  29. package/src/components/mail/MailViewChrome.tsx +1 -11
  30. package/src/components/mail/MailboxPane.tsx +4 -34
  31. package/src/components/mail/MessageActionMenu.tsx +6 -6
  32. package/src/components/mail/MessageAttachments.tsx +35 -0
  33. package/src/components/mail/MessageBody.tsx +1 -0
  34. package/src/components/mail/MessageBodyErrorBanner.test.ts +0 -30
  35. package/src/components/mail/MessageCard.tsx +16 -4
  36. package/src/components/mail/MessageList.run-reporting.test.ts +2 -46
  37. package/src/components/mail/MessageList.tsx +23 -11
  38. package/src/components/mail/MessageRow.render.test.ts +0 -7
  39. package/src/components/mail/MoveToTrigger.tsx +1 -1
  40. package/src/components/mail/RawMessageView.tsx +1 -1
  41. package/src/components/mail/SelectionWizardHost.search-entry.test.ts +0 -1
  42. package/src/components/mail/SelectionWizardHost.tsx +16 -102
  43. package/src/components/mail/authenticity-warning.stories.tsx +0 -21
  44. package/src/components/mail/daily-brief.stories.tsx +31 -17
  45. package/src/components/mail/enter-cursor-late-open.render.test.ts +197 -0
  46. package/src/components/mail/intelligence-drawer.stories.tsx +0 -2
  47. package/src/components/mail/message-gone.stories.tsx +44 -0
  48. package/src/components/mail/offer-times-reply.render.test.ts +25 -1
  49. package/src/components/mail/reclassify-remove-override.render.test.ts +2 -6
  50. package/src/components/overlay-escape.render.test.ts +7 -3
  51. package/src/components/settings/AccountFormPanel.render.test.ts +26 -3
  52. package/src/components/settings/AccountFormPanel.test.ts +1 -36
  53. package/src/components/settings/AccountFormPanel.tsx +5 -25
  54. package/src/components/settings/AccountServices.tsx +1 -1
  55. package/src/components/settings/AdvancedNavIcon.tsx +3 -3
  56. package/src/components/settings/CalendarFeedCard.tsx +2 -1
  57. package/src/components/settings/CalendarSubscribeCard.tsx +1 -1
  58. package/src/components/settings/CalendarSubscriptionStatus.tsx +1 -2
  59. package/src/components/settings/FilterActionError.tsx +1 -1
  60. package/src/components/settings/FilterEditor.render.test.ts +3 -27
  61. package/src/components/settings/FilterEditor.tsx +4 -24
  62. package/src/components/settings/FilterEditorSurface.tsx +0 -3
  63. package/src/components/settings/FiltersList.render.test.ts +5 -24
  64. package/src/components/settings/FiltersList.stories.tsx +0 -1
  65. package/src/components/settings/FiltersList.tsx +10 -35
  66. package/src/components/settings/WarningsNavIcon.tsx +12 -0
  67. package/src/components/settings/{QuarantinePanel.delimiter.test.ts → WarningsPanel.dom.test.ts} +64 -5
  68. package/src/components/settings/WarningsPanel.render.test.ts +56 -0
  69. package/src/components/settings/WarningsPanel.tsx +244 -0
  70. package/src/components/settings/accounts.stories.tsx +18 -0
  71. package/src/components/settings/advanced.stories.tsx +3 -125
  72. package/src/components/settings/settings-filter.stories.tsx +2 -42
  73. package/src/components/settings/warnings.stories.tsx +290 -0
  74. package/src/components/ui/ErrorBannerProvider.tsx +5 -3
  75. package/src/components/ui/error-banners.test.ts +1 -22
  76. package/src/components/ui/error-banners.ts +9 -20
  77. package/src/hooks/calendar/useCalendarCollectionWrites.ts +2 -1
  78. package/src/hooks/calendar/useCalendarSuggestions.ts +2 -1
  79. package/src/hooks/calendar/useCalendarWrites.ts +2 -1
  80. package/src/hooks/intelligence-calendar.render.test.ts +0 -1
  81. package/src/hooks/useDeleteMessages.test.ts +1 -42
  82. package/src/hooks/useFilters.test.ts +0 -8
  83. package/src/hooks/useFollowFocusOpen.render.test.ts +1 -11
  84. package/src/hooks/useIntelligenceData.test.ts +9 -47
  85. package/src/hooks/useIntelligenceData.ts +7 -100
  86. package/src/hooks/useLayoutTier.test.ts +0 -48
  87. package/src/hooks/useReportSpam.ts +1 -1
  88. package/src/hooks/useRescueCandidates.test.ts +0 -23
  89. package/src/hooks/useRulePreview.ts +8 -7
  90. package/src/hooks/useSearchTokenContext.ts +2 -3
  91. package/src/hooks/useStaleAccountSync.test.ts +0 -17
  92. package/src/hooks/useStarredThreads.test.ts +0 -10
  93. package/src/hooks/useTriggerSync.test.ts +0 -6
  94. package/src/hooks/useVisualViewport.render.test.ts +63 -0
  95. package/src/hooks/useWarningsAttention.ts +37 -0
  96. package/src/lib/account-order.test.ts +0 -10
  97. package/src/lib/adjacent-message.test.ts +0 -9
  98. package/src/lib/app-info.ts +0 -3
  99. package/src/lib/autodiscovery.test.ts +0 -47
  100. package/src/lib/bug-report.test.ts +38 -0
  101. package/src/lib/bug-report.ts +10 -7
  102. package/src/lib/bulk-action-copy.ts +1 -1
  103. package/src/lib/calendar-route.test.ts +0 -5
  104. package/src/lib/config-import.test.ts +3 -39
  105. package/src/lib/dedupe-thread-messages.test.ts +0 -4
  106. package/src/lib/desktop-breakpoint.test.ts +0 -8
  107. package/src/lib/drafts.test.ts +0 -23
  108. package/src/lib/fatal-error.test.ts +0 -19
  109. package/src/lib/folder-roles.test.ts +0 -4
  110. package/src/lib/hot-sync-window.test.ts +0 -5
  111. package/src/lib/list-header-chrome.ts +1 -1
  112. package/src/lib/mail-context.ts +1 -1
  113. package/src/lib/message-attachments.test.ts +77 -0
  114. package/src/lib/message-attachments.ts +31 -15
  115. package/src/lib/message-body-source.test.ts +21 -4
  116. package/src/lib/message-body-source.ts +5 -2
  117. package/src/lib/onboarding-completion.test.ts +0 -16
  118. package/src/lib/organize/filter-edit-model.test.ts +2 -32
  119. package/src/lib/organize/filter-edit-model.ts +6 -16
  120. package/src/lib/organize/organize-model.test.ts +7 -40
  121. package/src/lib/organize/organize-model.ts +16 -18
  122. package/src/lib/organize/rule-model.test.ts +24 -202
  123. package/src/lib/organize/rule-model.ts +19 -166
  124. package/src/lib/organize/search-to-rule.test.ts +2 -36
  125. package/src/lib/organize/search-to-rule.ts +1 -16
  126. package/src/lib/provider-presets.test.ts +1 -77
  127. package/src/lib/query-error-handler.test.ts +0 -61
  128. package/src/lib/query-escalation.integration.test.ts +4 -4
  129. package/src/lib/result-count.ts +1 -1
  130. package/src/lib/result-folder.ts +1 -1
  131. package/src/lib/rum-adapter.test.ts +0 -104
  132. package/src/lib/search-query.test.ts +0 -11
  133. package/src/lib/search-result.test.ts +2 -142
  134. package/src/lib/search-result.ts +3 -73
  135. package/src/lib/search-scope.test.ts +1 -92
  136. package/src/lib/search-scope.ts +0 -29
  137. package/src/lib/search-surface.ts +2 -2
  138. package/src/lib/search-token-index.test.ts +0 -8
  139. package/src/lib/search-tokens.ts +15 -16
  140. package/src/lib/starred-rows.test.ts +0 -4
  141. package/src/lib/sync-warning-entries.ts +33 -0
  142. package/src/lib/wizard-selection.ts +1 -1
  143. package/src/mocks/story-frame/mail-handlers.ts +12 -8
  144. package/src/mocks/story-frame/mail-world.ts +5 -0
  145. package/src/routeTree.gen.ts +21 -0
  146. package/src/routes/calendar/$view.$date/index.tsx +1 -1
  147. package/src/routes/calendar.tsx +2 -1
  148. package/src/routes/mail/brief.tsx +1 -1
  149. package/src/routes/mail/flagged.tsx +1 -1
  150. package/src/routes/mail.tsx +5 -3
  151. package/src/routes/settings/-accounts.test.ts +0 -5
  152. package/src/routes/settings/-filters.test.ts +0 -1
  153. package/src/routes/settings/-shared/help-copy.tsx +15 -0
  154. package/src/routes/settings/accounts.tsx +1 -1
  155. package/src/routes/settings/advanced.tsx +1 -4
  156. package/src/routes/settings/filters.tsx +1 -2
  157. package/src/routes/settings/folders.tsx +1 -1
  158. package/src/routes/settings/labels.tsx +1 -1
  159. package/src/routes/settings/senders.tsx +1 -1
  160. package/src/routes/settings/warnings.tsx +36 -0
  161. package/src/routes/settings.tsx +7 -0
  162. package/src/routing/wizard-history.test.ts +1 -45
  163. package/src/runtime-config.ts +5 -0
  164. package/src/test-support/fixtures.ts +1 -1
  165. package/src/components/compose/draft-discard-banner.test.ts +0 -76
  166. package/src/components/mail/ReadingPaneEmpty.render.test.ts +0 -42
  167. package/src/components/mail/SpamRescue.render.test.ts +0 -35
  168. package/src/components/mail/intelligence-semantic-off.render.test.ts +0 -111
  169. package/src/components/mail/selection-surface-rollcall.test.ts +0 -270
  170. package/src/components/settings/QuarantinePanel.render.test.ts +0 -71
  171. package/src/components/settings/QuarantinePanel.tsx +0 -178
  172. package/src/components/ui/DropdownMenu.tsx +0 -99
  173. package/src/components/ui/EmptyState.tsx +0 -11
  174. package/src/components/ui/ErrorBanner.render.test.ts +0 -104
  175. package/src/components/ui/ErrorBanner.stories.tsx +0 -132
  176. package/src/components/ui/ErrorBanner.tsx +0 -106
  177. package/src/components/ui/ErrorBannerStack.tsx +0 -35
  178. package/src/components/ui/ErrorState.render.test.ts +0 -82
  179. package/src/components/ui/ErrorState.tsx +0 -80
  180. package/src/components/ui/KeyboardShortcutsModal.tsx +0 -113
  181. package/src/hooks/useMoveMessages.test.ts +0 -63
  182. package/src/hooks/useOrganizeWiden.test.ts +0 -217
  183. package/src/hooks/useOrganizeWiden.ts +0 -112
  184. package/src/hooks/useSemanticSearch.ts +0 -132
  185. package/src/hooks/useSemanticSearchEnabled.ts +0 -23
  186. package/src/hooks/useVisualViewport.test.ts +0 -126
  187. package/src/lib/organize/sender-fallback.test.ts +0 -52
  188. package/src/lib/organize/sender-fallback.ts +0 -38
  189. package/src/lib/rescue-telemetry.test.ts +0 -75
  190. package/src/router.test.ts +0 -81
@@ -170,7 +170,6 @@ const sender: IntelligenceData = {
170
170
  summary: "Signed by example.test.",
171
171
  },
172
172
  category: { value: "personal" },
173
- similar: [],
174
173
  };
175
174
 
176
175
  let harness: DomHarness | undefined;
@@ -264,6 +263,28 @@ const creates = (): HttpCall[] =>
264
263
  (call) => call.method === "POST" && call.path.endsWith("/outbox"),
265
264
  );
266
265
 
266
+ const draftWrites = (): HttpCall[] => [
267
+ ...creates(),
268
+ ...(http?.calls ?? []).filter(
269
+ (call) =>
270
+ call.method === "PATCH" && call.path.endsWith(`/outbox/${DRAFT_ID}`),
271
+ ),
272
+ ];
273
+
274
+ // An autosave still waiting out its debounce outlives the test that scheduled
275
+ // it and lands on the next test's server, so a test that writes waits for it.
276
+ const savedHolding = (mounted: DomHarness, text: string): Promise<void> =>
277
+ mounted.waitFor(
278
+ () =>
279
+ draftWrites().some(
280
+ (call) =>
281
+ (call.body as { textBody?: string }).textBody?.includes(text) ===
282
+ true,
283
+ ),
284
+ `the draft to be saved holding ${JSON.stringify(text)}`,
285
+ AUTOSAVE_DEBOUNCE_MS + 3000,
286
+ );
287
+
267
288
  const mount = async (
268
289
  href: string,
269
290
  {
@@ -445,6 +466,7 @@ describe("offering other times from an invitation", () => {
445
466
  assert.match(plainBody(mounted)?.value ?? "", /^Hi/);
446
467
  assert.equal(router.state.location.pathname, `${MESSAGE_PATH}/reply`);
447
468
  assert.equal(seedInAddress(router), undefined);
469
+ await savedHolding(mounted, picked);
448
470
  });
449
471
 
450
472
  it("keeps an open reply-all a reply-all and adds the times to it", async () => {
@@ -465,6 +487,7 @@ describe("offering other times from an invitation", () => {
465
487
  assert.ok(composerText(mounted).includes("See you then"));
466
488
  assert.equal(router.state.location.pathname, href);
467
489
  assert.equal(seedInAddress(router), undefined);
490
+ await savedHolding(mounted, picked);
468
491
  });
469
492
 
470
493
  it("adds the times to a forward of the same message already open", async () => {
@@ -507,6 +530,7 @@ describe("offering other times from an invitation", () => {
507
530
  router.state.location.pathname,
508
531
  `/mail/${MAILBOX_ID}/${THREAD_ID}/${OTHER_ID}/reply`,
509
532
  );
533
+ await savedHolding(mounted, picked);
510
534
  });
511
535
 
512
536
  it("adds the times on top of a draft still loading when they were picked", async () => {
@@ -65,9 +65,7 @@ describe("the reclassify picker on a sender that already carries an override", (
65
65
 
66
66
  http = mockFetch((call) => {
67
67
  if (call.path.endsWith("/config")) {
68
- return makeConfig([makeAccount({ accountId: "acc-1" })], {
69
- semanticSearchEnabled: false,
70
- });
68
+ return makeConfig([makeAccount({ accountId: "acc-1" })]);
71
69
  }
72
70
  if (call.path.endsWith("/addresses/search")) {
73
71
  return { items: [stored] };
@@ -115,9 +113,7 @@ describe("the reclassify picker on a sender that already carries an override", (
115
113
  it("offers no removal on a sender that carries no override", async () => {
116
114
  http = mockFetch((call) => {
117
115
  if (call.path.endsWith("/config")) {
118
- return makeConfig([makeAccount({ accountId: "acc-1" })], {
119
- semanticSearchEnabled: false,
120
- });
116
+ return makeConfig([makeAccount({ accountId: "acc-1" })]);
121
117
  }
122
118
  if (call.path.endsWith("/addresses/search")) {
123
119
  return { items: [addressWith({})] };
@@ -11,12 +11,16 @@
11
11
  import "@remit/test-dom";
12
12
  import assert from "node:assert/strict";
13
13
  import { afterEach, beforeEach, describe, it } from "node:test";
14
- import { ConfirmDialog, useTriageKeyboard } from "@remit/ui";
14
+ import {
15
+ ConfirmDialog,
16
+ DropdownMenu,
17
+ DropdownMenuItem,
18
+ KeyboardShortcutsModal,
19
+ useTriageKeyboard,
20
+ } from "@remit/ui";
15
21
  import { act, createElement, type ReactNode } from "react";
16
22
  import { createRoot, type Root } from "react-dom/client";
17
23
  import { Drawer } from "./layout/Drawer";
18
- import { DropdownMenu, DropdownMenuItem } from "./ui/DropdownMenu";
19
- import { KeyboardShortcutsModal } from "./ui/KeyboardShortcutsModal";
20
24
 
21
25
  let root: Root;
22
26
  let container: HTMLElement;
@@ -202,9 +202,13 @@ describe("AccountFormPanel — provider presets", () => {
202
202
  });
203
203
 
204
204
  describe("AccountFormPanel — editing an account", () => {
205
- it("shows a placeholder for the stored password and keeps it on save", async () => {
205
+ it("starts the password blank and keeps the stored one on save", async () => {
206
206
  const dom = mount({ account });
207
- assert.equal(field(dom, "account-password").value, "••••••••••");
207
+ assert.equal(field(dom, "account-password").value, "");
208
+ assert.equal(
209
+ field(dom, "account-password").placeholder,
210
+ "Leave empty to keep current",
211
+ );
208
212
 
209
213
  await submit(dom);
210
214
 
@@ -223,7 +227,26 @@ describe("AccountFormPanel — editing an account", () => {
223
227
  assert.equal(body(saved()).password, "new-app-password");
224
228
  });
225
229
 
226
- it("tests the connection against the stored password rather than the placeholder", async () => {
230
+ it("reveals the password the user types, not a mask", () => {
231
+ const dom = mount({ account });
232
+ dom.type(field(dom, "account-password"), "new-app-password");
233
+
234
+ dom.click(dom.byLabel("Show password"));
235
+
236
+ const input = field(dom, "account-password");
237
+ assert.equal(input.getAttribute("type"), "text");
238
+ assert.equal(input.value, "new-app-password");
239
+ });
240
+
241
+ it("reveals an empty field before anything is typed", () => {
242
+ const dom = mount({ account });
243
+
244
+ dom.click(dom.byLabel("Show password"));
245
+
246
+ assert.equal(field(dom, "account-password").value, "");
247
+ });
248
+
249
+ it("tests the connection against the stored password while the field is blank", async () => {
227
250
  const dom = mount({ account });
228
251
  dom.click(dom.byText("button", "Test IMAP Connection"));
229
252
  await dom.flush();
@@ -1,9 +1,6 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
- import {
4
- deriveUseDifferentSmtpCreds,
5
- isOAuthMicrosoftAccount,
6
- } from "./AccountFormPanel.tsx";
3
+ import { deriveUseDifferentSmtpCreds } from "./AccountFormPanel.tsx";
7
4
 
8
5
  describe("deriveUseDifferentSmtpCreds", () => {
9
6
  test("returns true when smtpUsername differs from username", () => {
@@ -22,22 +19,6 @@ describe("deriveUseDifferentSmtpCreds", () => {
22
19
  assert.strictEqual(result, false);
23
20
  });
24
21
 
25
- test("returns false when smtpUsername is undefined", () => {
26
- const result = deriveUseDifferentSmtpCreds({
27
- username: "alice@example.com",
28
- smtpUsername: undefined,
29
- });
30
- assert.strictEqual(result, false);
31
- });
32
-
33
- test("returns false when smtpUsername is empty string", () => {
34
- const result = deriveUseDifferentSmtpCreds({
35
- username: "alice@example.com",
36
- smtpUsername: "",
37
- });
38
- assert.strictEqual(result, false);
39
- });
40
-
41
22
  test("returns false when smtpUsername is whitespace", () => {
42
23
  const result = deriveUseDifferentSmtpCreds({
43
24
  username: "alice@example.com",
@@ -54,19 +35,3 @@ describe("deriveUseDifferentSmtpCreds", () => {
54
35
  assert.strictEqual(result, false);
55
36
  });
56
37
  });
57
-
58
- describe("isOAuthMicrosoftAccount", () => {
59
- test("returns true for oauthMicrosoft authType", () => {
60
- assert.strictEqual(
61
- isOAuthMicrosoftAccount({ authType: "oauthMicrosoft" }),
62
- true,
63
- );
64
- });
65
-
66
- test("returns false for password authType", () => {
67
- assert.strictEqual(
68
- isOAuthMicrosoftAccount({ authType: "password" }),
69
- false,
70
- );
71
- });
72
- });
@@ -45,9 +45,6 @@ export const isOAuthMicrosoftAccount = (
45
45
  account: Pick<RemitImapAccountResponse, "authType">,
46
46
  ): boolean => account.authType === "oauthMicrosoft";
47
47
 
48
- // Placeholder shown when editing - indicates password exists but isn't shown
49
- const PASSWORD_PLACEHOLDER = "••••••••••";
50
-
51
48
  /**
52
49
  * Derive the initial state of the "use different SMTP credentials" checkbox
53
50
  * from a persisted account. Returns true when the account stores an SMTP
@@ -103,9 +100,6 @@ export const AccountFormPanel = ({
103
100
  const queryClient = useQueryClient();
104
101
  const isEditing = !!account;
105
102
 
106
- // Track if user has modified the password field
107
- const [passwordModified, setPasswordModified] = useState(false);
108
-
109
103
  // Selected provider preset (empty string = "Custom / other", manual entry)
110
104
  const [presetId, setPresetId] = useState("");
111
105
  // When a preset is selected its server fields are read-only until the
@@ -143,7 +137,7 @@ export const AccountFormPanel = ({
143
137
  email: account.email,
144
138
  displayName: account.displayName || "",
145
139
  username: account.username || "",
146
- password: PASSWORD_PLACEHOLDER, // Show placeholder to indicate password exists
140
+ password: "",
147
141
  imapHost: account.imapHost,
148
142
  imapPort: account.imapPort,
149
143
  imapTls: account.imapTls,
@@ -156,7 +150,6 @@ export const AccountFormPanel = ({
156
150
  smtpPassword: "",
157
151
  useDifferentSmtpCreds,
158
152
  });
159
- setPasswordModified(false);
160
153
  } else {
161
154
  form.reset({
162
155
  email: "",
@@ -173,7 +166,6 @@ export const AccountFormPanel = ({
173
166
  smtpStartTls: true,
174
167
  useDifferentSmtpCreds: false,
175
168
  });
176
- setPasswordModified(false);
177
169
  }
178
170
  }, [account, form]);
179
171
 
@@ -203,8 +195,7 @@ export const AccountFormPanel = ({
203
195
 
204
196
  const handleTestImap = () => {
205
197
  const values = form.getValues();
206
- // If editing and password not modified, send accountId so server uses stored password
207
- const useStoredPassword = isEditing && !passwordModified;
198
+ const useStoredPassword = isEditing && !values.password;
208
199
  testMutation.mutate({
209
200
  body: {
210
201
  accountId: useStoredPassword ? account?.accountId : undefined,
@@ -220,8 +211,7 @@ export const AccountFormPanel = ({
220
211
 
221
212
  const handleTestSmtp = () => {
222
213
  const values = form.getValues();
223
- // If editing and password not modified, send accountId so server uses stored password
224
- const useStoredPassword = isEditing && !passwordModified;
214
+ const useStoredPassword = isEditing && !values.password;
225
215
  testMutation.mutate({
226
216
  body: {
227
217
  accountId: useStoredPassword ? account?.accountId : undefined,
@@ -298,8 +288,7 @@ export const AccountFormPanel = ({
298
288
  if (isEditing && account) {
299
289
  const body = {
300
290
  ...baseBody,
301
- // Only send password if it was modified
302
- password: passwordModified ? values.password : undefined,
291
+ password: values.password || undefined,
303
292
  };
304
293
  updateMutation.mutate({ path: { accountId: account.accountId }, body });
305
294
  } else {
@@ -611,16 +600,7 @@ export const AccountFormPanel = ({
611
600
  </label>
612
601
  <PasswordInput
613
602
  id="account-password"
614
- {...form.register("password", {
615
- onChange: () => setPasswordModified(true),
616
- })}
617
- onFocus={() => {
618
- // Clear placeholder when user focuses the field
619
- if (isEditing && !passwordModified) {
620
- form.setValue("password", "");
621
- setPasswordModified(true);
622
- }
623
- }}
603
+ {...form.register("password")}
624
604
  placeholder={isEditing ? "Leave empty to keep current" : ""}
625
605
  />
626
606
  {form.formState.errors.password && (
@@ -10,10 +10,10 @@ import {
10
10
  type AccountServiceChange,
11
11
  AccountServiceChangeDialog,
12
12
  type AccountServiceIntent,
13
+ formatErrorMessage,
13
14
  } from "@remit/ui";
14
15
  import { useMutation, useQueryClient } from "@tanstack/react-query";
15
16
  import { useState } from "react";
16
- import { formatErrorMessage } from "@/components/ui/ErrorState";
17
17
  import {
18
18
  REDIRECT_STALL_MESSAGE,
19
19
  useRedirectEnded,
@@ -5,7 +5,7 @@
5
5
  * update gets outside the pane — no count, no copy, no action. Everywhere else
6
6
  * stays silent.
7
7
  */
8
- import { UpdateAvailableDot } from "@remit/ui";
8
+ import { AttentionDot } from "@remit/ui";
9
9
  import { Wrench } from "lucide-react";
10
10
  import { useOptionalSelfUpdate } from "@/hooks/use-system-update";
11
11
 
@@ -16,8 +16,8 @@ export function AdvancedNavIcon() {
16
16
  selfUpdate.surface.section.status === "available";
17
17
 
18
18
  return (
19
- <UpdateAvailableDot show={updateAvailable}>
19
+ <AttentionDot label={updateAvailable ? "Update available" : undefined}>
20
20
  <Wrench className="size-4" />
21
- </UpdateAvailableDot>
21
+ </AttentionDot>
22
22
  );
23
23
  }
@@ -15,10 +15,11 @@ import {
15
15
  CardHeader,
16
16
  CardTitle,
17
17
  ConfirmDialog,
18
+ ErrorState,
19
+ formatErrorMessage,
18
20
  } from "@remit/ui";
19
21
  import { Check, Copy, Link2, RefreshCw, Trash2 } from "lucide-react";
20
22
  import { type ReactNode, useEffect, useId, useRef, useState } from "react";
21
- import { ErrorState, formatErrorMessage } from "@/components/ui/ErrorState";
22
23
  import type { CalendarFeedState } from "@/hooks/calendar/useCalendarFeed";
23
24
  import {
24
25
  revokeFeedConfirmCopy,
@@ -12,11 +12,11 @@ import {
12
12
  CardBody,
13
13
  CardHeader,
14
14
  CardTitle,
15
+ formatErrorMessage,
15
16
  Input,
16
17
  } from "@remit/ui";
17
18
  import { CalendarPlus } from "lucide-react";
18
19
  import { type FormEvent, useId, useState } from "react";
19
- import { formatErrorMessage } from "@/components/ui/ErrorState";
20
20
 
21
21
  export interface CalendarSubscribeCardProps {
22
22
  isBusy: boolean;
@@ -6,9 +6,8 @@
6
6
  * The address itself is not drawn. For a Google feed it is the whole
7
7
  * credential, so only its host is named.
8
8
  */
9
- import { Badge, Banner, Button } from "@remit/ui";
9
+ import { Badge, Banner, Button, formatErrorMessage } from "@remit/ui";
10
10
  import { Pause, Play } from "lucide-react";
11
- import { formatErrorMessage } from "@/components/ui/ErrorState";
12
11
  import { subscriptionHostOf } from "@/lib/calendar-subscription";
13
12
  import { formatDatePreset } from "@/lib/format";
14
13
 
@@ -1,4 +1,4 @@
1
- import { ErrorState, formatErrorMessage } from "@/components/ui/ErrorState";
1
+ import { ErrorState, formatErrorMessage } from "@remit/ui";
2
2
  import { getErrorStatus } from "@/lib/error-classifier";
3
3
 
4
4
  interface FilterActionErrorProps {
@@ -6,8 +6,7 @@
6
6
  * The contract these tests pin: a predicate or action change patches the
7
7
  * predicate fields (so the server bumps `ruleChangedAt`) and offers a
8
8
  * re-back-apply carrying exactly the previewed predicate; a cosmetic rename
9
- * patches the name alone and offers nothing (RFC 034 Decision 3.2). A degraded
10
- * filter lists its widen inactive and lets the user take it off.
9
+ * patches the name alone and offers nothing (RFC 034 Decision 3.2).
11
10
  */
12
11
 
13
12
  import assert from "node:assert/strict";
@@ -48,7 +47,6 @@ const filterFixture = (
48
47
  scope: "Standing",
49
48
  state: "Active",
50
49
  disabledReason: "None",
51
- hasAnchor: false,
52
50
  ruleChangedAt: 100,
53
51
  actionChangedAt: 100,
54
52
  matchOperator: "And",
@@ -77,9 +75,8 @@ const RULE_ASSERTION_FIELDS = [
77
75
 
78
76
  /**
79
77
  * A backend that echoes the real update contract: the patch merges over the
80
- * stored filter, `hasAnchor` survives it (the update endpoint carries no anchor
81
- * field — `sanitizePatch` drops it), and `ruleChangedAt` advances only when the
82
- * patch touches a predicate or action field.
78
+ * stored filter, and `ruleChangedAt` advances only when the patch touches a
79
+ * predicate or action field.
83
80
  */
84
81
  const backend = (
85
82
  filter: RemitImapFilterResponse,
@@ -95,7 +92,6 @@ const backend = (
95
92
  return {
96
93
  ...filter,
97
94
  ...body,
98
- hasAnchor: filter.hasAnchor,
99
95
  ruleChangedAt: bumped ? filter.ruleChangedAt + 1 : filter.ruleChangedAt,
100
96
  };
101
97
  }
@@ -118,7 +114,6 @@ const backend = (
118
114
  const mount = (
119
115
  filter: RemitImapFilterResponse,
120
116
  responder?: Responder,
121
- semanticUnavailable = false,
122
117
  ): DomHarness => {
123
118
  http = mockFetch(responder ?? backend(filter));
124
119
  harness = createDomHarness();
@@ -128,7 +123,6 @@ const mount = (
128
123
  filter,
129
124
  folders: FOLDERS,
130
125
  labels: [],
131
- semanticUnavailable,
132
126
  onClose: () => undefined,
133
127
  }),
134
128
  );
@@ -248,24 +242,6 @@ describe("FilterEditor — rule change offers the re-back-apply", () => {
248
242
  });
249
243
  });
250
244
 
251
- describe("FilterEditor — degraded semantic filter (RFC 038 D4)", () => {
252
- it("lists the widen inactive and display-only — the anchor is fixed at creation", async () => {
253
- const dom = mount(filterFixture({ hasAnchor: true }), undefined, true);
254
- await settlePreview(dom);
255
-
256
- // The anchor this deployment cannot evaluate lists inactive.
257
- assert.match(dom.text(), /not available here/i);
258
-
259
- // The update endpoint carries no anchor, so the chip cannot be removed here:
260
- // no remove affordance, and a note says the anchor is fixed at creation.
261
- assert.equal(
262
- dom.query('[aria-label="Remove the similar-mail widen"]'),
263
- null,
264
- );
265
- assert.match(dom.text(), /similar-mail match is fixed to the message/i);
266
- });
267
- });
268
-
269
245
  describe("FilterEditor — scope and expiry are editable (reader #266)", () => {
270
246
  it("keeps the scope toggle and date input live, minus the once option", async () => {
271
247
  const dom = mount(
@@ -38,26 +38,17 @@ interface FilterEditorProps {
38
38
  /** The provider hierarchy separator the destination tree nests on. */
39
39
  delimiter?: string;
40
40
  labels: LabelOption[];
41
- /**
42
- * This deployment ships no vector pipeline, so a semantic anchor cannot be
43
- * evaluated (RFC 038 D4). The filter's widen chip lists inactive and the rule
44
- * matches by its literal clauses only.
45
- */
46
- semanticUnavailable?: boolean;
47
41
  onClose: () => void;
48
42
  }
49
43
 
50
44
  /**
51
45
  * Editing a standing filter in the same chip editor the Organize surface uses
52
46
  * (RFC 038 D6). The row's persisted rule opens in the editor — clauses, match
53
- * operator, move action, scope, expiry, and the semantic anchor as a widen
54
- * chip. Saving a predicate, action, scope, or expiry change bumps
47
+ * operator, move action, scope, and expiry. Saving a predicate, action, scope, or expiry change bumps
55
48
  * `ruleChangedAt` and offers, never runs, a re-back-apply over existing mail;
56
49
  * a cosmetic rename does neither (RFC 034 Decision 3.2, reader #266). The
57
50
  * re-apply carries exactly the previewed predicate and is held behind the
58
- * same settled-count commit gate as creation. The anchor stays fixed at
59
- * creation regardless — repointing it would silently change what the filter
60
- * matches, which deserves a new filter instead.
51
+ * same settled-count commit gate as creation.
61
52
  */
62
53
  export function FilterEditor({
63
54
  accountId,
@@ -65,13 +56,9 @@ export function FilterEditor({
65
56
  folders,
66
57
  delimiter,
67
58
  labels,
68
- semanticUnavailable = false,
69
59
  onClose,
70
60
  }: FilterEditorProps) {
71
- const original = useMemo(
72
- () => filterToRule(filter, semanticUnavailable),
73
- [filter, semanticUnavailable],
74
- );
61
+ const original = useMemo(() => filterToRule(filter), [filter]);
75
62
  const [rule, setRule] = useState<FilterRule>(original);
76
63
  const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
77
64
  const [offerReapply, setOfferReapply] = useState(false);
@@ -225,15 +212,8 @@ export function FilterEditor({
225
212
  delimiter={delimiter}
226
213
  labels={labels}
227
214
  preview={preview}
228
- // The update endpoint carries no anchor field at all (reader #266), so a
229
- // widen can be neither added nor removed here: the "…and similar" add is
230
- // never offered, and the existing chip is display-only (no
231
- // onRemoveWiden — anchorLocked enforces that regardless).
232
- // `semanticUnavailable` only drives the chip's inactive styling via
233
- // `filterToRule`.
234
- semanticAvailable={false}
235
215
  clauseFields={SUPPORTED_CLAUSE_FIELDS}
236
- anchorLocked
216
+ persisted
237
217
  clauseEdit={clauseEdit}
238
218
  onStartAddClause={startAddClause}
239
219
  onStartEditClause={startEditClause}
@@ -14,7 +14,6 @@ interface FilterEditorSurfaceProps {
14
14
  folders: readonly FolderTreeNode[];
15
15
  delimiter?: string;
16
16
  labels: LabelOption[];
17
- semanticUnavailable?: boolean;
18
17
  onClose: () => void;
19
18
  }
20
19
 
@@ -30,7 +29,6 @@ export function FilterEditorSurface({
30
29
  folders,
31
30
  delimiter,
32
31
  labels,
33
- semanticUnavailable,
34
32
  onClose,
35
33
  }: FilterEditorSurfaceProps) {
36
34
  const isDesktop = useIsDesktop();
@@ -42,7 +40,6 @@ export function FilterEditorSurface({
42
40
  folders={folders}
43
41
  delimiter={delimiter}
44
42
  labels={labels}
45
- semanticUnavailable={semanticUnavailable}
46
43
  onClose={onClose}
47
44
  />
48
45
  );
@@ -23,7 +23,6 @@ const filter = (
23
23
  scope: "Standing",
24
24
  state: "Active",
25
25
  disabledReason: "None",
26
- hasAnchor: true,
27
26
  ruleChangedAt: 0,
28
27
  actionChangedAt: 0,
29
28
  matchOperator: "And",
@@ -37,7 +36,6 @@ const filter = (
37
36
 
38
37
  const render = (
39
38
  filters: RemitImapFilterResponse[],
40
- semanticUnavailable = false,
41
39
  labelById: Map<string, LabelOption> = new Map(),
42
40
  run: { runFilterId?: string; runStatus?: FilterRunStatus } = {},
43
41
  ) =>
@@ -51,7 +49,6 @@ const render = (
51
49
  onToggle: () => undefined,
52
50
  onRunNow: () => undefined,
53
51
  ...run,
54
- semanticUnavailable,
55
52
  now: NOW,
56
53
  }) as never,
57
54
  );
@@ -74,27 +71,11 @@ describe("FiltersList", () => {
74
71
  assert.match(html, /Edit filter Travel/);
75
72
  });
76
73
 
77
- it("shows the widen chip for an anchored filter where the deployment can serve it", () => {
78
- const html = render([filter({ hasAnchor: true })]);
79
- assert.match(html, /and anything similar/i);
80
- });
81
-
82
- it("lists the widen chip inactive on a deployment that cannot evaluate it (D4)", () => {
83
- const html = render([filter({ hasAnchor: true })], true);
84
- assert.match(html, /not available here/i);
85
- assert.doesNotMatch(html, /and anything similar/i);
86
- });
87
-
88
- it("shows no widen chip for a purely literal filter", () => {
89
- const html = render([filter({ hasAnchor: false })]);
90
- assert.doesNotMatch(html, /similar/i);
91
- });
92
-
93
74
  it("shows the applied label's chip when the filter has a label action (issue #26)", () => {
94
75
  const labelById = new Map<string, LabelOption>([
95
76
  ["lbl-1", { id: "lbl-1", name: "Receipts", color: "Blue" }],
96
77
  ]);
97
- const html = render([filter({ actionLabelId: "lbl-1" })], false, labelById);
78
+ const html = render([filter({ actionLabelId: "lbl-1" })], labelById);
98
79
  assert.match(html, /Receipts/);
99
80
  });
100
81
 
@@ -185,7 +166,7 @@ describe("FiltersList", () => {
185
166
  });
186
167
 
187
168
  it("says the run is going on the filter that was run, and holds the button", () => {
188
- const html = render([filter({})], false, new Map(), {
169
+ const html = render([filter({})], new Map(), {
189
170
  runFilterId: "f-1",
190
171
  runStatus: { kind: "running" },
191
172
  });
@@ -195,7 +176,7 @@ describe("FiltersList", () => {
195
176
  });
196
177
 
197
178
  it("reports what the run filed once the job is done", () => {
198
- const html = render([filter({})], false, new Map(), {
179
+ const html = render([filter({})], new Map(), {
199
180
  runFilterId: "f-1",
200
181
  runStatus: { kind: "done", matched: 3, applied: 2 },
201
182
  });
@@ -203,7 +184,7 @@ describe("FiltersList", () => {
203
184
  });
204
185
 
205
186
  it("says so when nothing in the inbox matched", () => {
206
- const html = render([filter({})], false, new Map(), {
187
+ const html = render([filter({})], new Map(), {
207
188
  runFilterId: "f-1",
208
189
  runStatus: { kind: "done", matched: 0, applied: 0 },
209
190
  });
@@ -211,7 +192,7 @@ describe("FiltersList", () => {
211
192
  });
212
193
 
213
194
  it("puts no stale status on the row when the run failed", () => {
214
- const html = render([filter({})], false, new Map(), {
195
+ const html = render([filter({})], new Map(), {
215
196
  runFilterId: "f-1",
216
197
  runStatus: { kind: "failed", error: new Error("turned off") },
217
198
  });
@@ -39,7 +39,6 @@ const makeFilter = (
39
39
  scope: "Standing",
40
40
  state: "Active",
41
41
  disabledReason: "None",
42
- hasAnchor: false,
43
42
  ruleChangedAt: 0,
44
43
  actionChangedAt: 0,
45
44
  matchOperator: "And",