@remit/web-client 0.0.1

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 (298) hide show
  1. package/harness/build.mjs +84 -0
  2. package/harness/index.html +37 -0
  3. package/harness/vite-preset.ts +29 -0
  4. package/index.html +47 -0
  5. package/package.json +147 -0
  6. package/public/config.js +5 -0
  7. package/public/locales/de/common.json +1 -0
  8. package/public/locales/de/errors.json +1 -0
  9. package/public/locales/de/mail.json +13 -0
  10. package/public/locales/de/settings.json +1 -0
  11. package/public/locales/en/common.json +26 -0
  12. package/public/locales/en/errors.json +1 -0
  13. package/public/locales/en/mail.json +69 -0
  14. package/public/locales/en/settings.json +1 -0
  15. package/public/locales/fr/common.json +1 -0
  16. package/public/locales/fr/errors.json +1 -0
  17. package/public/locales/fr/mail.json +13 -0
  18. package/public/locales/fr/settings.json +1 -0
  19. package/public/locales/nl/common.json +1 -0
  20. package/public/locales/nl/errors.json +1 -0
  21. package/public/locales/nl/mail.json +13 -0
  22. package/public/locales/nl/settings.json +1 -0
  23. package/src/auth/AccountMenu.tsx +58 -0
  24. package/src/auth/AccountSession.tsx +19 -0
  25. package/src/auth/BetterAuthShell.tsx +198 -0
  26. package/src/auth/account-menu-mode.test.ts +64 -0
  27. package/src/auth/account-menu-mode.ts +28 -0
  28. package/src/auth/amplify-config.test.ts +158 -0
  29. package/src/auth/amplify-config.ts +60 -0
  30. package/src/auth/auth-error.test.ts +109 -0
  31. package/src/auth/auth-error.ts +107 -0
  32. package/src/auth/auth-interceptor.test.ts +86 -0
  33. package/src/auth/auth-interceptor.ts +23 -0
  34. package/src/auth/auth.css +334 -0
  35. package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
  36. package/src/auth/better-auth-config.ts +85 -0
  37. package/src/auth/better-auth-provider.tsx +20 -0
  38. package/src/auth/cognito/CognitoAccount.tsx +48 -0
  39. package/src/auth/cognito/CognitoShell.tsx +266 -0
  40. package/src/auth/cognito/cognito-token.test.ts +110 -0
  41. package/src/auth/cognito/cognito-token.ts +47 -0
  42. package/src/auth/cognito/cognito.css +21 -0
  43. package/src/auth/cognito-provider.tsx +22 -0
  44. package/src/auth/combined-provider.tsx +42 -0
  45. package/src/auth/composition.test.ts +136 -0
  46. package/src/auth/provider.tsx +63 -0
  47. package/src/auth/sign-out-visibility.test.ts +40 -0
  48. package/src/auth/sign-out-visibility.ts +10 -0
  49. package/src/auth/sign-up-disabled.test.ts +23 -0
  50. package/src/components/compose/AddressField.tsx +237 -0
  51. package/src/components/compose/ComposeBody.tsx +36 -0
  52. package/src/components/compose/ComposeForm.tsx +676 -0
  53. package/src/components/compose/ComposeProvider.tsx +133 -0
  54. package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
  55. package/src/components/compose/FromSelector.tsx +67 -0
  56. package/src/components/compose/FullCompose.tsx +82 -0
  57. package/src/components/compose/InlineCompose.tsx +29 -0
  58. package/src/components/compose/MobileComposeSheet.tsx +152 -0
  59. package/src/components/compose/PlateEditor.tsx +86 -0
  60. package/src/components/compose/PlateToolbar.tsx +110 -0
  61. package/src/components/compose/SubjectField.tsx +19 -0
  62. package/src/components/compose/draft-discard-banner.test.ts +76 -0
  63. package/src/components/compose/index.ts +6 -0
  64. package/src/components/compose/sanitize-quote-html.ts +23 -0
  65. package/src/components/layout/AppShellSkeleton.tsx +53 -0
  66. package/src/components/layout/ComposeFab.tsx +86 -0
  67. package/src/components/layout/Drawer.tsx +105 -0
  68. package/src/components/mail/AutoMovedIndicator.tsx +48 -0
  69. package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
  70. package/src/components/mail/BlockedImagesNotice.tsx +41 -0
  71. package/src/components/mail/BriefPane.tsx +289 -0
  72. package/src/components/mail/ConversationView.tsx +462 -0
  73. package/src/components/mail/DailyBrief.tsx +451 -0
  74. package/src/components/mail/DraftsView.tsx +279 -0
  75. package/src/components/mail/FlaggedList.tsx +170 -0
  76. package/src/components/mail/FlaggedPane.tsx +217 -0
  77. package/src/components/mail/IntelligencePane.test.ts +123 -0
  78. package/src/components/mail/IntelligencePane.tsx +441 -0
  79. package/src/components/mail/MailListHeader.tsx +187 -0
  80. package/src/components/mail/MailNav.tsx +70 -0
  81. package/src/components/mail/MailSidebarAdapter.tsx +351 -0
  82. package/src/components/mail/MailViewChrome.tsx +95 -0
  83. package/src/components/mail/MailboxPane.tsx +1219 -0
  84. package/src/components/mail/MessageActionMenu.tsx +368 -0
  85. package/src/components/mail/MessageBody.tsx +207 -0
  86. package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
  87. package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
  88. package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
  89. package/src/components/mail/MessageCard.tsx +401 -0
  90. package/src/components/mail/MessageDetail.tsx +153 -0
  91. package/src/components/mail/MessageList.tsx +804 -0
  92. package/src/components/mail/MessageListItem.tsx +267 -0
  93. package/src/components/mail/MessageToolbar.render.test.ts +57 -0
  94. package/src/components/mail/MessageToolbar.tsx +166 -0
  95. package/src/components/mail/MobileMessageBar.tsx +142 -0
  96. package/src/components/mail/MoveToTrigger.tsx +250 -0
  97. package/src/components/mail/NavMenuButton.tsx +25 -0
  98. package/src/components/mail/OutboxPane.tsx +531 -0
  99. package/src/components/mail/PullToRefresh.tsx +24 -0
  100. package/src/components/mail/RawMessageView.tsx +84 -0
  101. package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
  102. package/src/components/mail/SelectionToolbar.tsx +139 -0
  103. package/src/components/mail/SpamRescue.render.test.ts +35 -0
  104. package/src/components/mail/SpamRescue.tsx +138 -0
  105. package/src/components/mail/SwipeableMessageRow.tsx +148 -0
  106. package/src/components/mail/message-body-error-banner-content.ts +87 -0
  107. package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
  108. package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
  109. package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
  110. package/src/components/mail/organize/OrganizePanel.tsx +344 -0
  111. package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
  112. package/src/components/settings/AccountFormPanel.test.ts +72 -0
  113. package/src/components/settings/AccountFormPanel.tsx +942 -0
  114. package/src/components/settings/DangerZone.tsx +30 -0
  115. package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
  116. package/src/components/settings/DeleteAccountDialog.tsx +224 -0
  117. package/src/components/settings/FiltersList.render.test.ts +71 -0
  118. package/src/components/settings/FiltersList.tsx +93 -0
  119. package/src/components/settings/account-form-helpers.test.ts +172 -0
  120. package/src/components/settings/account-form-helpers.ts +105 -0
  121. package/src/components/ui/AppVersion.tsx +50 -0
  122. package/src/components/ui/BugReportButton.tsx +45 -0
  123. package/src/components/ui/ConfirmDialog.tsx +125 -0
  124. package/src/components/ui/DropdownMenu.tsx +105 -0
  125. package/src/components/ui/EmptyState.tsx +11 -0
  126. package/src/components/ui/ErrorBanner.tsx +88 -0
  127. package/src/components/ui/ErrorBannerProvider.tsx +77 -0
  128. package/src/components/ui/ErrorBannerStack.tsx +34 -0
  129. package/src/components/ui/ErrorState.tsx +80 -0
  130. package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
  131. package/src/components/ui/FatalErrorOverlay.tsx +151 -0
  132. package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
  133. package/src/components/ui/SlidePanel.tsx +72 -0
  134. package/src/components/ui/error-banners.test.ts +207 -0
  135. package/src/components/ui/error-banners.ts +111 -0
  136. package/src/hooks/queries/keys.ts +38 -0
  137. package/src/hooks/useArchiveMailbox.ts +104 -0
  138. package/src/hooks/useAutoMovedBadge.ts +77 -0
  139. package/src/hooks/useCurrentMailboxName.ts +99 -0
  140. package/src/hooks/useDebouncedValue.ts +12 -0
  141. package/src/hooks/useDeleteMessages.test.ts +103 -0
  142. package/src/hooks/useDeleteMessages.ts +226 -0
  143. package/src/hooks/useFilters.ts +112 -0
  144. package/src/hooks/useIntelligenceData.test.ts +269 -0
  145. package/src/hooks/useIntelligenceData.ts +284 -0
  146. package/src/hooks/useIsDark.ts +27 -0
  147. package/src/hooks/useKeyboardNavigation.ts +192 -0
  148. package/src/hooks/useLayoutTier.test.ts +47 -0
  149. package/src/hooks/useLayoutTier.ts +44 -0
  150. package/src/hooks/useLongPress.test.ts +291 -0
  151. package/src/hooks/useLongPress.ts +87 -0
  152. package/src/hooks/useMailboxAccount.ts +62 -0
  153. package/src/hooks/useMailboxNameIndex.ts +32 -0
  154. package/src/hooks/useMarkAsRead.test.ts +165 -0
  155. package/src/hooks/useMarkAsRead.ts +319 -0
  156. package/src/hooks/useMediaQuery.ts +39 -0
  157. package/src/hooks/useMessageBodyContent.test.ts +370 -0
  158. package/src/hooks/useMessageBodyContent.ts +380 -0
  159. package/src/hooks/useMoveMessages.test.ts +63 -0
  160. package/src/hooks/useMoveMessages.ts +221 -0
  161. package/src/hooks/useOrganizeJob.ts +90 -0
  162. package/src/hooks/useOrganizePreview.ts +40 -0
  163. package/src/hooks/useRescueCandidates.test.ts +217 -0
  164. package/src/hooks/useRescueCandidates.ts +48 -0
  165. package/src/hooks/useSaveDraft.ts +118 -0
  166. package/src/hooks/useSelection.test.ts +75 -0
  167. package/src/hooks/useSelection.ts +211 -0
  168. package/src/hooks/useSemanticSearch.ts +94 -0
  169. package/src/hooks/useSignature.ts +69 -0
  170. package/src/hooks/useStaleAccountSync.test.ts +211 -0
  171. package/src/hooks/useStaleAccountSync.ts +246 -0
  172. package/src/hooks/useSwipeNavigation.test.ts +35 -0
  173. package/src/hooks/useSwipeNavigation.ts +99 -0
  174. package/src/hooks/useToggleStar.ts +187 -0
  175. package/src/hooks/useToggleTrusted.test.ts +102 -0
  176. package/src/hooks/useToggleTrusted.ts +161 -0
  177. package/src/hooks/useTriageKeyboard.ts +103 -0
  178. package/src/hooks/useTriggerSync.test.ts +24 -0
  179. package/src/hooks/useTriggerSync.ts +58 -0
  180. package/src/hooks/useUpdateAddressFlags.ts +125 -0
  181. package/src/hooks/useVisualViewport.test.ts +127 -0
  182. package/src/hooks/useVisualViewport.ts +51 -0
  183. package/src/index.css +19 -0
  184. package/src/lib/account-order.test.ts +84 -0
  185. package/src/lib/account-order.ts +13 -0
  186. package/src/lib/adjacent-message.test.ts +43 -0
  187. package/src/lib/adjacent-message.ts +20 -0
  188. package/src/lib/api.ts +82 -0
  189. package/src/lib/app-info.ts +15 -0
  190. package/src/lib/auto-moved.test.ts +140 -0
  191. package/src/lib/auto-moved.ts +68 -0
  192. package/src/lib/autodiscovery.test.ts +94 -0
  193. package/src/lib/autodiscovery.ts +354 -0
  194. package/src/lib/brief.test.ts +366 -0
  195. package/src/lib/brief.ts +180 -0
  196. package/src/lib/bug-report.test.ts +213 -0
  197. package/src/lib/bug-report.ts +176 -0
  198. package/src/lib/client.ts +36 -0
  199. package/src/lib/console-errors.test.ts +64 -0
  200. package/src/lib/console-errors.ts +92 -0
  201. package/src/lib/conversation-target.test.ts +68 -0
  202. package/src/lib/conversation-target.ts +59 -0
  203. package/src/lib/display-category.ts +20 -0
  204. package/src/lib/drafts.test.ts +241 -0
  205. package/src/lib/drafts.ts +121 -0
  206. package/src/lib/error-classifier.test.ts +133 -0
  207. package/src/lib/error-classifier.ts +96 -0
  208. package/src/lib/fatal-error.test.ts +97 -0
  209. package/src/lib/fatal-error.ts +165 -0
  210. package/src/lib/folder-roles.test.ts +113 -0
  211. package/src/lib/folder-roles.ts +112 -0
  212. package/src/lib/format.test.ts +115 -0
  213. package/src/lib/format.ts +198 -0
  214. package/src/lib/i18n.ts +36 -0
  215. package/src/lib/intelligence-pref.test.ts +57 -0
  216. package/src/lib/intelligence-pref.ts +25 -0
  217. package/src/lib/keymap-dispatch.test.ts +178 -0
  218. package/src/lib/keymap-dispatch.ts +187 -0
  219. package/src/lib/keymap.test.ts +49 -0
  220. package/src/lib/keymap.ts +178 -0
  221. package/src/lib/mail-context.ts +64 -0
  222. package/src/lib/mail-route.test.ts +80 -0
  223. package/src/lib/mail-route.ts +44 -0
  224. package/src/lib/message-body-source.test.ts +98 -0
  225. package/src/lib/message-body-source.ts +48 -0
  226. package/src/lib/move-targets.test.ts +195 -0
  227. package/src/lib/move-targets.ts +74 -0
  228. package/src/lib/onboarding-completion.test.ts +64 -0
  229. package/src/lib/onboarding-completion.ts +23 -0
  230. package/src/lib/organize/filter-status.test.ts +99 -0
  231. package/src/lib/organize/filter-status.ts +86 -0
  232. package/src/lib/organize/organize-copy.test.ts +93 -0
  233. package/src/lib/organize/organize-copy.ts +61 -0
  234. package/src/lib/organize/organize-model.test.ts +104 -0
  235. package/src/lib/organize/organize-model.ts +105 -0
  236. package/src/lib/organize/organize-poll.test.ts +37 -0
  237. package/src/lib/organize/organize-poll.ts +28 -0
  238. package/src/lib/outbox-status.test.ts +64 -0
  239. package/src/lib/outbox-status.ts +75 -0
  240. package/src/lib/plate-serializer.ts +164 -0
  241. package/src/lib/provider-presets.test.ts +96 -0
  242. package/src/lib/provider-presets.ts +81 -0
  243. package/src/lib/query-error-handler.test.ts +144 -0
  244. package/src/lib/query-error-handler.ts +34 -0
  245. package/src/lib/query-escalation.integration.test.ts +138 -0
  246. package/src/lib/recent-searches.test.ts +53 -0
  247. package/src/lib/recent-searches.ts +34 -0
  248. package/src/lib/rescue-candidates.test.ts +94 -0
  249. package/src/lib/rescue-candidates.ts +35 -0
  250. package/src/lib/rescue-telemetry.test.ts +75 -0
  251. package/src/lib/rescue-telemetry.ts +55 -0
  252. package/src/lib/rum-adapter.test.ts +299 -0
  253. package/src/lib/rum-adapter.ts +107 -0
  254. package/src/lib/saved-searches.test.ts +80 -0
  255. package/src/lib/saved-searches.ts +52 -0
  256. package/src/lib/search-pending.test.ts +61 -0
  257. package/src/lib/search-pending.ts +24 -0
  258. package/src/lib/search-query.test.ts +35 -0
  259. package/src/lib/search-query.ts +11 -0
  260. package/src/lib/search-result.test.ts +160 -0
  261. package/src/lib/search-result.ts +102 -0
  262. package/src/lib/search-schemas.ts +34 -0
  263. package/src/lib/search-token-index.test.ts +112 -0
  264. package/src/lib/search-token-index.ts +56 -0
  265. package/src/lib/search-tokens.test.ts +218 -0
  266. package/src/lib/search-tokens.ts +133 -0
  267. package/src/lib/telemetry-context.tsx +9 -0
  268. package/src/lib/telemetry.ts +17 -0
  269. package/src/lib/theme-preference.ts +46 -0
  270. package/src/lib/theme.ts +33 -0
  271. package/src/lib/utils.ts +4 -0
  272. package/src/main.tsx +10 -0
  273. package/src/routeTree.gen.ts +392 -0
  274. package/src/router.test.ts +81 -0
  275. package/src/router.tsx +31 -0
  276. package/src/routes/__root.tsx +69 -0
  277. package/src/routes/index.tsx +7 -0
  278. package/src/routes/mail/$mailboxId.tsx +27 -0
  279. package/src/routes/mail/flagged.tsx +37 -0
  280. package/src/routes/mail/index.tsx +41 -0
  281. package/src/routes/mail/outbox.tsx +13 -0
  282. package/src/routes/mail.tsx +413 -0
  283. package/src/routes/onboarding.tsx +35 -0
  284. package/src/routes/settings/-accounts.test.ts +65 -0
  285. package/src/routes/settings/accounts.tsx +542 -0
  286. package/src/routes/settings/advanced.tsx +55 -0
  287. package/src/routes/settings/appearance.tsx +140 -0
  288. package/src/routes/settings/filters.tsx +141 -0
  289. package/src/routes/settings/folders.tsx +216 -0
  290. package/src/routes/settings/index.tsx +5 -0
  291. package/src/routes/settings/senders.tsx +466 -0
  292. package/src/routes/settings/suggested-vips.tsx +10 -0
  293. package/src/routes/settings.tsx +66 -0
  294. package/src/runtime-config.ts +75 -0
  295. package/src/shell/index.tsx +98 -0
  296. package/src/types/index.ts +73 -0
  297. package/src/vite-env.d.ts +4 -0
  298. package/vite.base.ts +43 -0
@@ -0,0 +1,80 @@
1
+ import { AlertCircle } from "lucide-react";
2
+
3
+ interface ErrorStateProps {
4
+ title?: string;
5
+ error: unknown;
6
+ onRetry?: () => void;
7
+ variant?: "block" | "inline";
8
+ }
9
+
10
+ export const formatErrorMessage = (error: unknown): string => {
11
+ if (error instanceof Error) return error.message;
12
+ if (typeof error === "string") return error;
13
+ if (
14
+ typeof error === "object" &&
15
+ error !== null &&
16
+ "message" in error &&
17
+ typeof (error as { message: unknown }).message === "string"
18
+ ) {
19
+ return (error as { message: string }).message;
20
+ }
21
+ return "An unexpected error occurred";
22
+ };
23
+
24
+ export const ErrorState = ({
25
+ title = "Couldn't load content",
26
+ error,
27
+ onRetry,
28
+ variant = "block",
29
+ }: ErrorStateProps) => {
30
+ const message = formatErrorMessage(error);
31
+
32
+ if (variant === "inline") {
33
+ return (
34
+ <div
35
+ role="alert"
36
+ className="flex items-start gap-2 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm"
37
+ >
38
+ <AlertCircle
39
+ className="size-4 shrink-0 text-danger mt-0.5"
40
+ aria-hidden="true"
41
+ />
42
+ <div className="flex-1 min-w-0">
43
+ <p className="font-medium text-danger">{title}</p>
44
+ <p className="text-fg-muted mt-0.5 break-words">{message}</p>
45
+ </div>
46
+ {onRetry && (
47
+ <button
48
+ type="button"
49
+ onClick={onRetry}
50
+ className="shrink-0 text-sm font-medium text-accent hover:underline"
51
+ >
52
+ Retry
53
+ </button>
54
+ )}
55
+ </div>
56
+ );
57
+ }
58
+
59
+ return (
60
+ <div
61
+ role="alert"
62
+ className="flex flex-col items-center justify-center gap-3 p-8 text-center"
63
+ >
64
+ <AlertCircle className="size-8 text-danger" aria-hidden="true" />
65
+ <div>
66
+ <p className="font-medium text-danger">{title}</p>
67
+ <p className="text-sm text-fg-muted mt-1 break-words">{message}</p>
68
+ </div>
69
+ {onRetry && (
70
+ <button
71
+ type="button"
72
+ onClick={onRetry}
73
+ className="inline-flex items-center rounded-md border border-line bg-canvas px-4 py-2 text-sm font-medium hover:bg-surface-raised transition-colors"
74
+ >
75
+ Retry
76
+ </button>
77
+ )}
78
+ </div>
79
+ );
80
+ };
@@ -0,0 +1,66 @@
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 { FatalError } from "@/lib/fatal-error";
6
+ import { FatalErrorScreen } from "./FatalErrorOverlay";
7
+
8
+ const recoverableFatal: FatalError = {
9
+ error: new Error("Request failed with status 500"),
10
+ message: "Request failed with status 500",
11
+ correlationId: "abc-123",
12
+ at: 0,
13
+ recoverable: true,
14
+ };
15
+
16
+ const deterministicFatal: FatalError = {
17
+ error: new Error("date value is not finite in DateTimeFormat format()"),
18
+ message: "date value is not finite in DateTimeFormat format()",
19
+ correlationId: "def-456",
20
+ at: 0,
21
+ recoverable: false,
22
+ stack: "Error: date value is not finite\n at format",
23
+ componentStack: "\n at AccountsSettings\n at Route",
24
+ };
25
+
26
+ const render = (fatal: FatalError): string =>
27
+ renderToString(createElement(FatalErrorScreen, { fatal }) as never);
28
+
29
+ describe("FatalErrorScreen — the full-screen red escalation page", () => {
30
+ it("renders a loud red full-screen overlay (not a benign grey label)", () => {
31
+ const html = render(recoverableFatal);
32
+ assert.match(html, /data-testid="fatal-error-overlay"/);
33
+ assert.match(html, /bg-red-700/);
34
+ assert.match(html, /fixed inset-0/);
35
+ assert.match(html, /role="alert"/);
36
+ });
37
+
38
+ it("shows the error message and correlation reference", () => {
39
+ const html = render(recoverableFatal);
40
+ assert.match(html, /Request failed with status 500/);
41
+ assert.match(html, /abc-123/);
42
+ });
43
+
44
+ it("always offers Report a bug and Copy full details", () => {
45
+ for (const fatal of [recoverableFatal, deterministicFatal]) {
46
+ const html = render(fatal);
47
+ assert.match(html, /Report a bug/);
48
+ assert.match(html, /Copy full details/);
49
+ }
50
+ });
51
+ });
52
+
53
+ describe("FatalErrorScreen — recoverable vs fatal affordances", () => {
54
+ it("a recoverable error offers Retry and no dead-end safe-route link", () => {
55
+ const html = render(recoverableFatal);
56
+ assert.match(html, /Retry/);
57
+ assert.doesNotMatch(html, /Go to inbox/);
58
+ });
59
+
60
+ it("a deterministic fatal offers a safe route out — never a re-crashing Retry", () => {
61
+ const html = render(deterministicFatal);
62
+ assert.doesNotMatch(html, />Retry</);
63
+ assert.match(html, /Go to inbox/);
64
+ assert.match(html, /href="\/mail"/);
65
+ });
66
+ });
@@ -0,0 +1,151 @@
1
+ import { AlertOctagon } from "lucide-react";
2
+ import { useEffect, useState } from "react";
3
+ import {
4
+ type BugReportSeed,
5
+ buildBugReportContext,
6
+ buildBugReportDetails,
7
+ buildGitHubIssueUrl,
8
+ } from "@/lib/bug-report";
9
+ import {
10
+ type FatalError,
11
+ getCurrentFatalError,
12
+ subscribeFatalError,
13
+ } from "@/lib/fatal-error";
14
+
15
+ /** Safe route to escape a deterministically-crashing page. `/` redirects here. */
16
+ const SAFE_ROUTE = "/mail";
17
+
18
+ /**
19
+ * The full-screen red escalation page. A first-party fatal — an escalated 5xx,
20
+ * or a render exception caught by the route error boundary — takes over the
21
+ * entire viewport here: loud, red, impossible to miss. Never a benign grey
22
+ * label.
23
+ *
24
+ * It is intentionally NOT dismissible: a fatal means the app is in an unknown
25
+ * state. The affordances depend on whether the error is recoverable:
26
+ * - recoverable (transient 5xx / network / abort) → Retry the same action.
27
+ * - fatal (deterministic render exception) → NO Retry (it re-crashes); offer a
28
+ * way out to a safe route instead.
29
+ * Both states can Report a bug (prefilled with the stacktrace) and Copy full
30
+ * details.
31
+ */
32
+ export const FatalErrorOverlay = () => {
33
+ const [fatal, setFatal] = useState<FatalError | null>(() =>
34
+ getCurrentFatalError(),
35
+ );
36
+
37
+ useEffect(() => subscribeFatalError(setFatal), []);
38
+
39
+ if (!fatal) return null;
40
+
41
+ return <FatalErrorScreen fatal={fatal} />;
42
+ };
43
+
44
+ interface FatalErrorScreenProps {
45
+ fatal: FatalError;
46
+ /**
47
+ * What Retry does for a recoverable error. The route error boundary passes
48
+ * TanStack's `reset`; without it, Retry reloads the page. Ignored for a
49
+ * non-recoverable fatal (no Retry is shown).
50
+ */
51
+ onRetry?: () => void;
52
+ }
53
+
54
+ const seedFromFatal = (fatal: FatalError): BugReportSeed => ({
55
+ errorMessage: fatal.message,
56
+ stack: fatal.stack,
57
+ componentStack: fatal.componentStack,
58
+ });
59
+
60
+ /**
61
+ * Presentational screen — exported so it can be rendered directly in tests and
62
+ * by the route-level error boundary without going through the subscription.
63
+ */
64
+ export const FatalErrorScreen = ({ fatal, onRetry }: FatalErrorScreenProps) => {
65
+ const [copied, setCopied] = useState(false);
66
+
67
+ const handleReload = () => {
68
+ window.location.reload();
69
+ };
70
+
71
+ const handleRetry = () => {
72
+ if (onRetry) {
73
+ onRetry();
74
+ return;
75
+ }
76
+ handleReload();
77
+ };
78
+
79
+ const handleReportBug = () => {
80
+ const url = buildGitHubIssueUrl(
81
+ buildBugReportContext(seedFromFatal(fatal)),
82
+ );
83
+ window.open(url, "_blank", "noopener,noreferrer");
84
+ };
85
+
86
+ const handleCopyDetails = async () => {
87
+ const details = buildBugReportDetails(
88
+ buildBugReportContext(seedFromFatal(fatal)),
89
+ );
90
+ await navigator.clipboard.writeText(details);
91
+ setCopied(true);
92
+ window.setTimeout(() => setCopied(false), 2000);
93
+ };
94
+
95
+ const description = fatal.recoverable
96
+ ? "The server returned an unexpected error. This is not your fault — nothing was saved or sent. Retry, or report a bug so we can fix it."
97
+ : "Something went wrong and this page can't recover. This is not your fault — nothing was saved or sent. Go to your inbox, or report a bug so we can fix it.";
98
+
99
+ return (
100
+ <div
101
+ role="alert"
102
+ aria-live="assertive"
103
+ data-testid="fatal-error-overlay"
104
+ className="fixed inset-0 z-[1000] flex flex-col items-center justify-center gap-6 bg-red-700 p-6 text-center text-white"
105
+ >
106
+ <AlertOctagon className="size-16 shrink-0" aria-hidden="true" />
107
+ <div className="max-w-lg space-y-3">
108
+ <h1 className="text-2xl font-bold tracking-tight">Something broke</h1>
109
+ <p className="text-base text-red-50">{description}</p>
110
+ <p className="break-words font-mono text-sm text-red-100">
111
+ {fatal.message}
112
+ </p>
113
+ <p className="font-mono text-xs text-red-200">
114
+ Reference: {fatal.correlationId}
115
+ </p>
116
+ </div>
117
+ <div className="flex flex-wrap items-center justify-center gap-3">
118
+ {fatal.recoverable ? (
119
+ <button
120
+ type="button"
121
+ onClick={handleRetry}
122
+ className="inline-flex min-h-11 items-center rounded-md bg-white px-5 text-sm font-semibold text-red-700 transition-colors hover:bg-red-50"
123
+ >
124
+ Retry
125
+ </button>
126
+ ) : (
127
+ <a
128
+ href={SAFE_ROUTE}
129
+ className="inline-flex min-h-11 items-center rounded-md bg-white px-5 text-sm font-semibold text-red-700 transition-colors hover:bg-red-50"
130
+ >
131
+ Go to inbox
132
+ </a>
133
+ )}
134
+ <button
135
+ type="button"
136
+ onClick={handleReportBug}
137
+ className="inline-flex min-h-11 items-center rounded-md border border-white/70 px-5 text-sm font-semibold text-white transition-colors hover:bg-white/10"
138
+ >
139
+ Report a bug
140
+ </button>
141
+ <button
142
+ type="button"
143
+ onClick={handleCopyDetails}
144
+ className="inline-flex min-h-11 items-center rounded-md border border-white/70 px-5 text-sm font-semibold text-white transition-colors hover:bg-white/10"
145
+ >
146
+ {copied ? "Copied" : "Copy full details"}
147
+ </button>
148
+ </div>
149
+ </div>
150
+ );
151
+ };
@@ -0,0 +1,120 @@
1
+ import { Kbd } from "@remit/ui";
2
+ import { X } from "lucide-react";
3
+ import { Fragment, useCallback, useEffect } from "react";
4
+ import { KEY_HINT_GROUPS } from "@/lib/keymap";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ interface KeyboardShortcutsModalProps {
8
+ isOpen: boolean;
9
+ onClose: () => void;
10
+ }
11
+
12
+ /**
13
+ * The `?` help overlay (#429). Renders the full triage key map from the single
14
+ * source of truth (`@/lib/keymap`) using the remit-ui `Kbd` component, so the
15
+ * displayed bindings can never drift from what the dispatcher actually routes.
16
+ */
17
+ export const KeyboardShortcutsModal = ({
18
+ isOpen,
19
+ onClose,
20
+ }: KeyboardShortcutsModalProps) => {
21
+ // Close on Escape
22
+ const handleKeyDown = useCallback(
23
+ (event: KeyboardEvent) => {
24
+ if (event.key === "Escape") {
25
+ event.preventDefault();
26
+ onClose();
27
+ }
28
+ },
29
+ [onClose],
30
+ );
31
+
32
+ useEffect(() => {
33
+ if (!isOpen) return;
34
+ window.addEventListener("keydown", handleKeyDown);
35
+ return () => window.removeEventListener("keydown", handleKeyDown);
36
+ }, [isOpen, handleKeyDown]);
37
+
38
+ if (!isOpen) return null;
39
+
40
+ return (
41
+ <div
42
+ className="fixed inset-0 z-50 flex items-center justify-center"
43
+ role="dialog"
44
+ aria-modal="true"
45
+ aria-label="Keyboard shortcuts"
46
+ onClick={onClose}
47
+ onKeyDown={(e) => e.key === "Escape" && onClose()}
48
+ >
49
+ {/* Backdrop */}
50
+ <div className="absolute inset-0 bg-canvas/80 backdrop-blur-sm" />
51
+
52
+ {/* Modal */}
53
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: stops event propagation from the modal content; keyboard is handled by the outer wrapper */}
54
+ <div
55
+ className={cn(
56
+ "relative z-10 max-h-[85vh] w-full max-w-2xl overflow-y-auto",
57
+ "rounded-lg border border-line bg-surface shadow-lg",
58
+ "p-6",
59
+ )}
60
+ onClick={(e) => e.stopPropagation()}
61
+ onKeyDown={(e) => {
62
+ if (e.key !== "Escape") e.stopPropagation();
63
+ }}
64
+ >
65
+ {/* Header */}
66
+ <div className="mb-6 flex items-center justify-between">
67
+ <h2 className="text-lg font-semibold">Keyboard shortcuts</h2>
68
+ <button
69
+ type="button"
70
+ onClick={onClose}
71
+ className="p-1 text-fg-muted transition-colors hover:text-fg"
72
+ aria-label="Close"
73
+ >
74
+ <X className="size-5" />
75
+ </button>
76
+ </div>
77
+
78
+ {/* Sections — two columns on wider modals */}
79
+ <div className="grid gap-x-8 gap-y-6 sm:grid-cols-2">
80
+ {KEY_HINT_GROUPS.map((group) => (
81
+ <div key={group.title}>
82
+ <h3 className="mb-3 text-sm font-medium text-fg-muted">
83
+ {group.title}
84
+ </h3>
85
+ <div className="space-y-2">
86
+ {group.hints.map((hint) => (
87
+ <div
88
+ key={`${hint.action}-${hint.keys.join("+")}`}
89
+ className="flex items-center justify-between gap-4"
90
+ >
91
+ <span className="text-sm">{hint.description}</span>
92
+ <span className="flex shrink-0 items-center gap-1">
93
+ {hint.keys.map((key, index) => (
94
+ <Fragment key={key}>
95
+ {index > 0 && hint.keys[0] === "g" && (
96
+ <span className="text-2xs text-fg-subtle">
97
+ then
98
+ </span>
99
+ )}
100
+ <Kbd>{key}</Kbd>
101
+ </Fragment>
102
+ ))}
103
+ </span>
104
+ </div>
105
+ ))}
106
+ </div>
107
+ </div>
108
+ ))}
109
+ </div>
110
+
111
+ {/* Footer */}
112
+ <div className="mt-6 border-t border-line pt-4">
113
+ <p className="text-center text-xs text-fg-muted">
114
+ Press <Kbd>Esc</Kbd> to close
115
+ </p>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ );
120
+ };
@@ -0,0 +1,72 @@
1
+ import { X } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "../../lib/utils";
4
+
5
+ interface SlidePanelProps {
6
+ isOpen: boolean;
7
+ onClose: () => void;
8
+ title: string;
9
+ children: ReactNode;
10
+ footer?: ReactNode;
11
+ }
12
+
13
+ export const SlidePanel = ({
14
+ isOpen,
15
+ onClose,
16
+ title,
17
+ children,
18
+ footer,
19
+ }: SlidePanelProps) => (
20
+ <>
21
+ {/* Backdrop overlay */}
22
+ {/* biome-ignore lint/a11y/useSemanticElements: full-screen backdrop overlay; <button> default styles would break fixed inset positioning */}
23
+ <div
24
+ className={cn(
25
+ "fixed inset-0 bg-black/30 z-40 transition-opacity",
26
+ isOpen ? "opacity-100" : "opacity-0 pointer-events-none",
27
+ )}
28
+ onClick={onClose}
29
+ onKeyDown={(e) => e.key === "Escape" && onClose()}
30
+ role="button"
31
+ tabIndex={0}
32
+ aria-label="Close panel"
33
+ />
34
+
35
+ {/* Slide-in panel */}
36
+ <div
37
+ className={cn(
38
+ "fixed top-0 right-0 h-full w-full sm:w-[400px] sm:max-w-[90vw] bg-canvas border-l border-line shadow-xl z-50",
39
+ "transform transition-transform duration-200 ease-out",
40
+ isOpen ? "translate-x-0" : "translate-x-full",
41
+ )}
42
+ role="dialog"
43
+ aria-modal="true"
44
+ aria-labelledby="slide-panel-title"
45
+ >
46
+ {/* Panel header */}
47
+ <div className="flex items-center justify-between px-4 h-14 border-b border-line">
48
+ <h2 id="slide-panel-title" className="font-semibold">
49
+ {title}
50
+ </h2>
51
+ <button
52
+ type="button"
53
+ onClick={onClose}
54
+ className="p-1.5 rounded-md hover:bg-surface-raised transition-colors"
55
+ aria-label="Close"
56
+ >
57
+ <X className="size-5" />
58
+ </button>
59
+ </div>
60
+
61
+ {/* Panel content */}
62
+ <div className="h-[calc(100%-3.5rem)] flex flex-col">
63
+ <div className="flex-1 overflow-auto p-4">{children}</div>
64
+ {footer && (
65
+ <div className="flex justify-end gap-3 p-4 border-t border-line bg-canvas">
66
+ {footer}
67
+ </div>
68
+ )}
69
+ </div>
70
+ </div>
71
+ </>
72
+ );
@@ -0,0 +1,207 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import {
4
+ appendBanner,
5
+ buildEntry,
6
+ buildMutationErrorBanner,
7
+ dismissBanner,
8
+ type ErrorBannerEntry,
9
+ formatErrorDetail,
10
+ isMessageNotFoundError,
11
+ } from "./error-banners.js";
12
+
13
+ const make = (
14
+ overrides: Partial<ErrorBannerEntry> & { id: string },
15
+ ): ErrorBannerEntry => ({
16
+ severity: "error",
17
+ title: "default",
18
+ detail: undefined,
19
+ createdAt: 0,
20
+ ...overrides,
21
+ });
22
+
23
+ describe("buildEntry", () => {
24
+ test("defaults to error severity", () => {
25
+ const entry = buildEntry({ title: "boom" }, "id-1", 1000);
26
+ assert.equal(entry.severity, "error");
27
+ assert.equal(entry.title, "boom");
28
+ assert.equal(entry.id, "id-1");
29
+ assert.equal(entry.createdAt, 1000);
30
+ });
31
+
32
+ test("preserves explicit severity and detail", () => {
33
+ const entry = buildEntry(
34
+ { severity: "warning", title: "watch", detail: "context" },
35
+ "id-2",
36
+ 2000,
37
+ );
38
+ assert.equal(entry.severity, "warning");
39
+ assert.equal(entry.detail, "context");
40
+ });
41
+ });
42
+
43
+ describe("appendBanner", () => {
44
+ test("adds an entry at the end of the stack", () => {
45
+ const a = make({ id: "1", title: "first" });
46
+ const b = make({ id: "2", title: "second" });
47
+ const got = appendBanner([a], b);
48
+ assert.deepStrictEqual(
49
+ got.map((e) => e.id),
50
+ ["1", "2"],
51
+ );
52
+ });
53
+
54
+ test("dedupes entries with the same severity, title and detail", () => {
55
+ const a = make({
56
+ id: "1",
57
+ title: "Couldn't mark as read",
58
+ detail: "Network error",
59
+ });
60
+ const b = make({
61
+ id: "2",
62
+ title: "Couldn't mark as read",
63
+ detail: "Network error",
64
+ });
65
+ const got = appendBanner([a], b);
66
+ assert.equal(got.length, 1);
67
+ assert.equal(got[0]?.id, "2");
68
+ });
69
+
70
+ test("keeps differing detail as separate banners", () => {
71
+ const a = make({
72
+ id: "1",
73
+ title: "Couldn't mark as read",
74
+ detail: "Network error",
75
+ });
76
+ const b = make({
77
+ id: "2",
78
+ title: "Couldn't mark as read",
79
+ detail: "Server error",
80
+ });
81
+ const got = appendBanner([a], b);
82
+ assert.equal(got.length, 2);
83
+ });
84
+
85
+ test("caps the stack at 5 by dropping the oldest", () => {
86
+ let stack: ErrorBannerEntry[] = [];
87
+ for (let i = 0; i < 7; i++) {
88
+ stack = appendBanner(
89
+ stack,
90
+ make({ id: `${i}`, title: `t${i}`, createdAt: i }),
91
+ );
92
+ }
93
+ assert.equal(stack.length, 5);
94
+ assert.deepStrictEqual(
95
+ stack.map((e) => e.id),
96
+ ["2", "3", "4", "5", "6"],
97
+ );
98
+ });
99
+ });
100
+
101
+ describe("dismissBanner", () => {
102
+ test("removes the matching entry and leaves the rest in order", () => {
103
+ const stack = [make({ id: "1" }), make({ id: "2" }), make({ id: "3" })];
104
+ const got = dismissBanner(stack, "2");
105
+ assert.deepStrictEqual(
106
+ got.map((e) => e.id),
107
+ ["1", "3"],
108
+ );
109
+ });
110
+
111
+ test("returns the same shape when the id is unknown", () => {
112
+ const stack = [make({ id: "1" })];
113
+ const got = dismissBanner(stack, "nope");
114
+ assert.equal(got.length, 1);
115
+ });
116
+ });
117
+
118
+ describe("formatErrorDetail", () => {
119
+ test("returns the message of an Error", () => {
120
+ assert.equal(formatErrorDetail(new Error("boom")), "boom");
121
+ });
122
+
123
+ test("returns a non-empty string as-is", () => {
124
+ assert.equal(formatErrorDetail("nope"), "nope");
125
+ });
126
+
127
+ test("returns undefined for empty strings", () => {
128
+ assert.equal(formatErrorDetail(""), undefined);
129
+ });
130
+
131
+ test("reads object .message", () => {
132
+ assert.equal(formatErrorDetail({ message: "shape" }), "shape");
133
+ });
134
+
135
+ test("returns undefined for null/undefined", () => {
136
+ assert.equal(formatErrorDetail(null), undefined);
137
+ assert.equal(formatErrorDetail(undefined), undefined);
138
+ });
139
+
140
+ test("returns undefined when nothing matches", () => {
141
+ assert.equal(formatErrorDetail(42), undefined);
142
+ assert.equal(formatErrorDetail({}), undefined);
143
+ });
144
+ });
145
+
146
+ describe("isMessageNotFoundError (#212)", () => {
147
+ // Regression: clicking a stale inbox row used to hit `describeMessage`
148
+ // and surface the raw "Message not found: <id>" string. The frontend now
149
+ // detects that error shape and shows a "deleted" empty state.
150
+
151
+ test("matches the backend's NotFoundError JSON body", () => {
152
+ assert.equal(
153
+ isMessageNotFoundError({
154
+ message: "Message not found: alice-msg-aaaaaaa",
155
+ }),
156
+ true,
157
+ );
158
+ });
159
+
160
+ test("matches an Error instance with the same message", () => {
161
+ assert.equal(
162
+ isMessageNotFoundError(new Error("Message not found: bob-msg-aaaaaaa")),
163
+ true,
164
+ );
165
+ });
166
+
167
+ test("does not match unrelated errors", () => {
168
+ assert.equal(
169
+ isMessageNotFoundError({ message: "Mailbox not found: x" }),
170
+ false,
171
+ );
172
+ assert.equal(isMessageNotFoundError(new Error("boom")), false);
173
+ assert.equal(isMessageNotFoundError(null), false);
174
+ assert.equal(isMessageNotFoundError(undefined), false);
175
+ assert.equal(isMessageNotFoundError({}), false);
176
+ });
177
+ });
178
+
179
+ describe("buildMutationErrorBanner — previously-silent mutations now surface", () => {
180
+ test("uses the server error message when present (real detail, not a dot)", () => {
181
+ const banner = buildMutationErrorBanner(
182
+ "Couldn't save signature",
183
+ "The signature wasn't saved.",
184
+ new Error("Address already in use"),
185
+ );
186
+ assert.equal(banner.title, "Couldn't save signature");
187
+ assert.equal(banner.detail, "Address already in use");
188
+ });
189
+
190
+ test("falls back to a human message when the error carries none", () => {
191
+ const banner = buildMutationErrorBanner(
192
+ "Couldn't discard draft",
193
+ "The draft wasn't deleted.",
194
+ {},
195
+ );
196
+ assert.equal(banner.detail, "The draft wasn't deleted.");
197
+ });
198
+
199
+ test("reads .message off a plain object error body", () => {
200
+ const banner = buildMutationErrorBanner(
201
+ "Couldn't resend message",
202
+ "The message wasn't sent.",
203
+ { message: "SMTP relay refused" },
204
+ );
205
+ assert.equal(banner.detail, "SMTP relay refused");
206
+ });
207
+ });