@remit/web-client 0.0.12 → 0.0.13

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/web-client",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -1,16 +1,8 @@
1
- import { useLocation, useNavigate } from "@tanstack/react-router";
1
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import { useLocation } from "@tanstack/react-router";
2
3
  import { Pencil } from "lucide-react";
3
- import { startTransition } from "react";
4
4
  import { useCompose } from "@/components/compose/ComposeProvider";
5
-
6
- /**
7
- * Returns true when `pathname` is `/mail/<id>` for any id other than
8
- * the reserved `outbox` segment. That route owns a `FullCompose` mount
9
- * point, so the FAB can open compose in-place. From every other route
10
- * the FAB has to send the user there first.
11
- */
12
- const isOnMailboxRoute = (pathname: string): boolean =>
13
- /^\/mail\/(?!outbox)[^/?]+/.test(pathname);
5
+ import { useGlobalCompose } from "@/hooks/useComposeTarget";
14
6
 
15
7
  /**
16
8
  * Primary mobile surfaces where the FAB belongs: anywhere under
@@ -21,30 +13,34 @@ const isOnMailboxRoute = (pathname: string): boolean =>
21
13
  const isOnPrimaryMobileRoute = (pathname: string): boolean =>
22
14
  pathname.startsWith("/mail") || pathname.startsWith("/settings");
23
15
 
16
+ interface ComposeFabProps {
17
+ accounts: RemitImapAccountResponse[];
18
+ }
19
+
24
20
  /**
25
21
  * Floating Action Button for composing a new message. Mobile-only.
26
22
  *
27
23
  * Layout follows Material 3: 56×56 surface, 16px from the right and
28
24
  * bottom edges (plus the iOS safe-area inset). Hidden when any of:
29
- * - Viewport is `≥ md` (desktop has compose in the sidebar / etc.).
25
+ * - Viewport is `≥ lg` (1024px), where the top bar owns compose. The
26
+ * `/mail` shell also stops mounting the FAB above that width; the
27
+ * `lg:hidden` class covers the pre-hydration frame.
30
28
  * - The compose surface is already open.
31
29
  * - The user is reading a thread (`?selectedMessageId=…`) — the
32
30
  * conversation's Reply/Forward action bar covers that workflow.
33
31
  * - The user is not on a primary mobile route (`/mail` or
34
32
  * `/settings`).
35
33
  *
36
- * From routes that don't host a `FullCompose` (everything except
37
- * `/mail/<mailboxId>` i.e. `/mail`, `/mail/outbox`, `/settings/*`)
38
- * tapping the FAB opens compose state and navigates to `/mail`, which
39
- * the index loader redirects to the preferred mailbox (PR #138). The
40
- * compose state survives the route transition because `ComposeProvider`
41
- * lives in `__root.tsx`, so the destination route mounts straight into
42
- * compose.
34
+ * The tap itself is `useGlobalCompose`, shared with the desktop top bar:
35
+ * it opens compose in place on routes that mount `FullCompose` and
36
+ * otherwise carries the user to a real mailbox that does. Compose state
37
+ * survives that transition because `ComposeProvider` lives in
38
+ * `__root.tsx`.
43
39
  */
44
- export const ComposeFab = () => {
45
- const { state, openCompose } = useCompose();
40
+ export const ComposeFab = ({ accounts }: ComposeFabProps) => {
41
+ const { state } = useCompose();
46
42
  const location = useLocation();
47
- const navigate = useNavigate();
43
+ const compose = useGlobalCompose(accounts);
48
44
 
49
45
  const search = location.search as Record<string, unknown> | undefined;
50
46
  const isReadingThread =
@@ -58,21 +54,10 @@ export const ComposeFab = () => {
58
54
  )
59
55
  return null;
60
56
 
61
- const handleClick = () => {
62
- // Wrap in startTransition so the suspension that ComposeForm
63
- // triggers on first mount (lazy chunks, queries) doesn't
64
- // snap the surrounding chrome into a route-level fallback.
65
- startTransition(() => {
66
- openCompose({ mode: "new" });
67
- });
68
- if (isOnMailboxRoute(location.pathname)) return;
69
- navigate({ to: "/mail" });
70
- };
71
-
72
57
  return (
73
58
  <button
74
59
  type="button"
75
- onClick={handleClick}
60
+ onClick={compose}
76
61
  aria-label="Compose new message"
77
62
  className="lg:hidden fixed right-4 z-30 h-14 w-14 rounded-full bg-accent text-accent-fg shadow-lg flex items-center justify-center hover:opacity-90 active:scale-95 transition-all"
78
63
  style={{
@@ -0,0 +1,64 @@
1
+ /**
2
+ * MailTopBar — the app's one search surface and its global actions.
3
+ *
4
+ * Mounted above every pane by the `/mail` shell, so the field spans the nav,
5
+ * the list and the reading pane. It is the app's search, not the list's: the
6
+ * list header drops its own field wherever this bar is mounted, so exactly one
7
+ * search input exists on the page and the "/" shortcut has one target.
8
+ *
9
+ * The actions here are the ones that belong to the app rather than to whatever
10
+ * is currently listed or open — compose, bug report, account. Reply, delete,
11
+ * move and the rest stay on the reading pane's own toolbar, under this bar.
12
+ */
13
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
14
+ import { AppTopBar, Button, SearchBar } from "@remit/ui";
15
+ import { SquarePen } from "lucide-react";
16
+ import { AccountMenu } from "@/auth/AccountMenu";
17
+ import { BugReportButton } from "@/components/ui/BugReportButton";
18
+ import { useGlobalCompose } from "@/hooks/useComposeTarget";
19
+ import { tooltipForAction } from "@/lib/keymap";
20
+ import { useMailContext } from "@/lib/mail-context";
21
+
22
+ interface MailTopBarProps {
23
+ accounts: RemitImapAccountResponse[];
24
+ }
25
+
26
+ export function MailTopBar({ accounts }: MailTopBarProps) {
27
+ const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
28
+ useMailContext();
29
+ const compose = useGlobalCompose(accounts);
30
+
31
+ return (
32
+ <AppTopBar
33
+ leading={
34
+ <span className="px-1 text-sm font-semibold tracking-tight text-fg">
35
+ remit
36
+ </span>
37
+ }
38
+ search={
39
+ <SearchBar
40
+ value={searchInput}
41
+ onChange={onSearchChange}
42
+ onClear={onSearchClear}
43
+ onClearQuery={onSearchClearQuery}
44
+ placeholder="Search all mail"
45
+ size="lg"
46
+ />
47
+ }
48
+ actions={
49
+ <>
50
+ <Button
51
+ variant="ghost"
52
+ size="sm"
53
+ icon={<SquarePen className="size-4" />}
54
+ title={`Compose ${tooltipForAction("compose")}`}
55
+ aria-label="Compose"
56
+ onClick={compose}
57
+ />
58
+ <BugReportButton />
59
+ <AccountMenu />
60
+ </>
61
+ }
62
+ />
63
+ );
64
+ }
@@ -184,26 +184,15 @@ function BriefList() {
184
184
  */
185
185
  function BriefReading() {
186
186
  const { conversation } = useBriefPane();
187
- const {
188
- onToggleIntelligence,
189
- searchInput,
190
- onSearchChange,
191
- onSearchClear,
192
- onSearchClearQuery,
193
- } = useMailContext();
187
+ const { onToggleIntelligence } = useMailContext();
194
188
 
195
189
  return (
196
190
  <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
197
191
  <MessageToolbar
198
192
  hasThread={Boolean(conversation)}
199
- onCompose={() => undefined}
200
193
  intelligenceOpen={false}
201
194
  showIntelligenceToggle={false}
202
195
  onToggleIntelligence={onToggleIntelligence}
203
- searchValue={searchInput}
204
- onSearchChange={onSearchChange}
205
- onSearchClear={onSearchClear}
206
- onSearchClearQuery={onSearchClearQuery}
207
196
  />
208
197
  <div className="min-h-0 flex-1 overflow-hidden">
209
198
  {conversation ? (
@@ -128,26 +128,15 @@ function FlaggedListSlot() {
128
128
  */
129
129
  function FlaggedReading() {
130
130
  const { selectedThread } = useFlaggedPane();
131
- const {
132
- onToggleIntelligence,
133
- searchInput,
134
- onSearchChange,
135
- onSearchClear,
136
- onSearchClearQuery,
137
- } = useMailContext();
131
+ const { onToggleIntelligence } = useMailContext();
138
132
 
139
133
  return (
140
134
  <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
141
135
  <MessageToolbar
142
136
  hasThread={Boolean(selectedThread)}
143
- onCompose={() => undefined}
144
137
  intelligenceOpen={false}
145
138
  showIntelligenceToggle={false}
146
139
  onToggleIntelligence={onToggleIntelligence}
147
- searchValue={searchInput}
148
- onSearchChange={onSearchChange}
149
- onSearchClear={onSearchClear}
150
- onSearchClearQuery={onSearchClearQuery}
151
140
  />
152
141
  <div className="min-h-0 flex-1 overflow-hidden">
153
142
  {selectedThread ? (
@@ -31,7 +31,7 @@ import {
31
31
  useAppShellLayout,
32
32
  } from "@remit/ui";
33
33
  import { type ReactNode, useState } from "react";
34
- import { useLayoutTier } from "@/hooks/useLayoutTier";
34
+ import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
35
35
  import { useMailContext } from "@/lib/mail-context";
36
36
  import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
37
37
  import {
@@ -168,11 +168,15 @@ export function MailListHeader({
168
168
  <MailHeader
169
169
  title={title}
170
170
  unreadCount={unreadCount}
171
- // The list pane is narrow even on desktop, and the reading-pane
172
- // toolbar owns the wide search; keep the header's search compact (a
173
- // magnifier) at every width. On phone the magnifier opens the
174
- // full-screen takeover above; on tablet it expands over the title.
171
+ // Desktop mounts the app top bar, which owns search for the whole
172
+ // shell the list header shows no field there, so the page never
173
+ // has two search inputs competing for "/" and for focus. Below
174
+ // desktop the header keeps a compact magnifier: on phone it opens
175
+ // the full-screen takeover above, on tablet it expands over the
176
+ // title. `isSinglePaneTier` is the same predicate the shell gates
177
+ // the top bar on, so the two cannot drift into zero or two fields.
175
178
  isDesktop={false}
179
+ showSearch={isSinglePaneTier(tier)}
176
180
  onMenuClick={() => layout?.openNav()}
177
181
  searchValue={searchInput}
178
182
  onSearchChange={onSearchChange}
@@ -202,7 +202,6 @@ interface MailboxPaneContextValue {
202
202
  onToolbarDiscardDraft: () => void;
203
203
  onToolbarMove: (destMailboxId: string) => void;
204
204
  composeState: ReturnType<typeof useCompose>["state"];
205
- openCompose: () => void;
206
205
  closeCompose: () => void;
207
206
  hasRemitDraftOpen: boolean;
208
207
  // Phone actions
@@ -843,7 +842,6 @@ function MailboxPaneProvider({
843
842
  onToolbarDiscardDraft: handleToolbarDiscardDraft,
844
843
  onToolbarMove: handleToolbarMove,
845
844
  composeState,
846
- openCompose: handleNewCompose,
847
845
  closeCompose,
848
846
  hasRemitDraftOpen,
849
847
  onBack: goBack,
@@ -1044,10 +1042,7 @@ function MailboxReading() {
1044
1042
  onToolbarDiscardDraft,
1045
1043
  onToolbarMove,
1046
1044
  composeState,
1047
- openCompose,
1048
1045
  } = useMailboxPane();
1049
- const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
1050
- useMailContext();
1051
1046
  const tier = useLayoutTier();
1052
1047
  const isDesktop = tier === "desktop";
1053
1048
  const hasThread = Boolean(conversation);
@@ -1078,14 +1073,9 @@ function MailboxReading() {
1078
1073
  <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
1079
1074
  <MessageToolbar
1080
1075
  hasThread={hasThread}
1081
- onCompose={openCompose}
1082
1076
  intelligenceOpen={showIntelligence}
1083
1077
  showIntelligenceToggle={isDesktop && hasThread}
1084
1078
  onToggleIntelligence={onToggleIntelligence}
1085
- searchValue={searchInput}
1086
- onSearchChange={onSearchChange}
1087
- onSearchClear={onSearchClear}
1088
- onSearchClearQuery={onSearchClearQuery}
1089
1079
  onReply={hasThread ? onToolbarReply : undefined}
1090
1080
  onReplyAll={hasThread ? onToolbarReplyAll : undefined}
1091
1081
  onForward={hasThread ? onToolbarForward : undefined}
@@ -12,6 +12,7 @@ import { describe, it } from "node:test";
12
12
  import { MailActionToolbar } from "@remit/ui";
13
13
  import React, { createElement } from "react";
14
14
  import { renderToString } from "react-dom/server";
15
+ import { MessageToolbar } from "./MessageToolbar";
15
16
 
16
17
  // The node test loader transpiles remit-ui's `.tsx` (resolved through
17
18
  // node_modules) with the classic JSX runtime, which references a global
@@ -55,3 +56,41 @@ describe("MailActionToolbar never disables its action buttons (#799)", () => {
55
56
  assert.doesNotMatch(html, /aria-label="Archive"/);
56
57
  });
57
58
  });
59
+
60
+ /**
61
+ * The reading-pane toolbar carries message context only (#49). Search, compose,
62
+ * bug report and the account menu moved up into the app top bar, which spans
63
+ * every pane. Two search fields on one screen fight over "/" and over focus, so
64
+ * the absence of one here is the thing worth pinning.
65
+ */
66
+ describe("MessageToolbar carries message context only (#49)", () => {
67
+ const render = (hasThread: boolean): string =>
68
+ renderToString(
69
+ createElement(MessageToolbar, {
70
+ hasThread,
71
+ intelligenceOpen: false,
72
+ showIntelligenceToggle: true,
73
+ onToggleIntelligence: () => undefined,
74
+ }) as never,
75
+ );
76
+
77
+ it("mounts no search field", () => {
78
+ for (const hasThread of [false, true]) {
79
+ assert.doesNotMatch(render(hasThread), /aria-label="Search mail"/);
80
+ }
81
+ });
82
+
83
+ it("carries no global actions — compose, bug report, account", () => {
84
+ const html = render(true);
85
+ assert.doesNotMatch(html, /aria-label="Compose"/);
86
+ assert.doesNotMatch(html, /aria-label="Report a bug"/);
87
+ assert.doesNotMatch(html, /aria-label="Account menu"/);
88
+ });
89
+
90
+ it("keeps the message verbs and the intelligence toggle", () => {
91
+ const html = render(true);
92
+ assert.match(html, /aria-label="Reply"/);
93
+ assert.match(html, /aria-label="Star"/);
94
+ assert.match(html, /intelligence sidebar/);
95
+ });
96
+ });
@@ -1,22 +1,17 @@
1
- import {
2
- Button,
3
- type MailAction,
4
- MailActionToolbar,
5
- SearchBar,
6
- } from "@remit/ui";
7
- import { Info, SquarePen } from "lucide-react";
1
+ import { Button, type MailAction, MailActionToolbar } from "@remit/ui";
2
+ import { Info } from "lucide-react";
8
3
  import { useState } from "react";
9
- import { AccountMenu } from "@/auth/AccountMenu";
10
- import { BugReportButton } from "@/components/ui/BugReportButton";
11
4
  import { tooltipForAction } from "@/lib/keymap";
12
5
  import { MoveToTrigger } from "./MoveToTrigger";
13
6
 
14
7
  /**
15
8
  * Message action toolbar on the pane-header datum (40px, the shared
16
- * `--spacing-pane-header`). The reading pane's verbs reply / reply-all /
17
- * forward, then delete / move / flag — Apple-Mail-style ghost icon buttons,
18
- * then the search field top-right (still filters the current list), compose
19
- * (✎) and the intelligence toggle (#422).
9
+ * `--spacing-pane-header`). Everything here acts on the open message: reply /
10
+ * reply-all / forward, then delete / move / flag — Apple-Mail-style ghost icon
11
+ * buttons — and the intelligence toggle (#422).
12
+ *
13
+ * Search, compose, bug report and the account menu are not message context;
14
+ * they live in the app top bar above every pane (`MailTopBar`, #49).
20
15
  *
21
16
  * Buttons are always pressable (never `disabled`): with no thread open a press
22
17
  * is a no-op that surfaces a one-line inline explanation rather than greying
@@ -24,7 +19,6 @@ import { MoveToTrigger } from "./MoveToTrigger";
24
19
  */
25
20
  export interface MessageToolbarProps {
26
21
  hasThread: boolean;
27
- onCompose: () => void;
28
22
  intelligenceOpen: boolean;
29
23
  /**
30
24
  * Whether the intelligence toggle is shown at all. The rail is contextual
@@ -33,12 +27,6 @@ export interface MessageToolbarProps {
33
27
  */
34
28
  showIntelligenceToggle: boolean;
35
29
  onToggleIntelligence: () => void;
36
- searchValue: string;
37
- onSearchChange: (value: string) => void;
38
- /** Full clear (X button): drops the query and any selected thread (#538). */
39
- onSearchClear: () => void;
40
- /** Query-only clear (Esc): drops the query, keeps the thread open (#489). */
41
- onSearchClearQuery?: () => void;
42
30
 
43
31
  /* ---- wired action callbacks (omit to keep the no-op-explain behaviour) ---- */
44
32
  onReply?: () => void;
@@ -71,14 +59,9 @@ const OPEN_FIRST = "Open a message first";
71
59
 
72
60
  export const MessageToolbar = ({
73
61
  hasThread,
74
- onCompose,
75
62
  intelligenceOpen,
76
63
  showIntelligenceToggle,
77
64
  onToggleIntelligence,
78
- searchValue,
79
- onSearchChange,
80
- onSearchClear,
81
- onSearchClearQuery,
82
65
  onReply,
83
66
  onReplyAll,
84
67
  onForward,
@@ -120,27 +103,6 @@ export const MessageToolbar = ({
120
103
  ) : undefined
121
104
  }
122
105
  >
123
- {/* Apple Mail geometry: search sits top-right over the message area
124
- but still filters the current list. Reuses the wired SearchBar
125
- (the global "/" focus shortcut + Escape handling live in it). */}
126
- <div className="w-64 min-w-40 shrink">
127
- <SearchBar
128
- value={searchValue}
129
- onChange={onSearchChange}
130
- onClear={onSearchClear}
131
- onClearQuery={onSearchClearQuery}
132
- placeholder="Search mail"
133
- />
134
- </div>
135
- <span className="mx-1 h-4 w-px bg-line" aria-hidden />
136
- <Button
137
- variant="ghost"
138
- size="sm"
139
- icon={<SquarePen className="size-4" />}
140
- title={`Compose ${tooltipForAction("compose")}`}
141
- aria-label="Compose"
142
- onClick={onCompose}
143
- />
144
106
  {showIntelligenceToggle && (
145
107
  <Button
146
108
  variant="ghost"
@@ -159,8 +121,6 @@ export const MessageToolbar = ({
159
121
  }
160
122
  />
161
123
  )}
162
- <BugReportButton />
163
- <AccountMenu />
164
124
  </MailActionToolbar>
165
125
  );
166
126
  };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Where a global compose has to land.
3
+ *
4
+ * The compose surface (`FullCompose`) is mounted by the mailbox route only, so
5
+ * compose started from anywhere else — the daily brief, flagged, outbox — has
6
+ * to carry the user to a mailbox first. The target is the first account's
7
+ * inbox, falling back to its first mailbox.
8
+ *
9
+ * Both compose entry points use this: the top bar's button on desktop and the
10
+ * mobile `ComposeFab`. One resolver, so the two surfaces cannot disagree about
11
+ * which routes host the compose surface.
12
+ */
13
+ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
14
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
15
+ import { useQueries } from "@tanstack/react-query";
16
+ import { useLocation, useNavigate } from "@tanstack/react-router";
17
+ import { startTransition, useCallback } from "react";
18
+ import { useCompose } from "@/components/compose/ComposeProvider";
19
+ import { hostsComposeSurface } from "@/lib/compose-routes";
20
+ import { buildMailboxRoleMap } from "@/lib/folder-roles";
21
+
22
+ /**
23
+ * The mailbox id a global compose should navigate to, or undefined if none is
24
+ * known yet.
25
+ *
26
+ * Accounts resolve in order and an account whose mailbox query is still in
27
+ * flight blocks rather than being skipped: skipping hands back a later
28
+ * account's inbox and then silently swaps the target once the earlier query
29
+ * settles.
30
+ */
31
+ export function useComposeTargetMailboxId(
32
+ accounts: RemitImapAccountResponse[],
33
+ ): string | undefined {
34
+ const mailboxQueries = useQueries({
35
+ queries: accounts.map((account) => ({
36
+ ...mailboxOperationsListMailboxesOptions({
37
+ path: { accountId: account.accountId },
38
+ }),
39
+ staleTime: Infinity,
40
+ })),
41
+ });
42
+
43
+ for (const [index, account] of accounts.entries()) {
44
+ const query = mailboxQueries[index];
45
+ if (!query || query.isPending) return undefined;
46
+ const mailboxes = query.data?.items ?? [];
47
+ if (mailboxes.length === 0) continue;
48
+ const roleMap = buildMailboxRoleMap(account.folderAppointments);
49
+ const inbox = mailboxes.find(
50
+ (mailbox) => roleMap.get(mailbox.mailboxId) === "inbox",
51
+ );
52
+ return (inbox ?? mailboxes[0])?.mailboxId;
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ /**
58
+ * A compose action that works from every view: opens compose in place when the
59
+ * current route already hosts the surface, otherwise navigates to the target
60
+ * mailbox — compose state lives in `ComposeProvider` (mounted at the root), so
61
+ * it survives the transition and the destination mounts straight into it.
62
+ *
63
+ * With no target resolved yet — a cold load whose mailbox queries have not
64
+ * settled, or accounts with no mailboxes — the action does nothing. Opening
65
+ * compose state first would leave it open with nothing rendering it, and it
66
+ * would then pop up unprompted on the next navigation.
67
+ */
68
+ export function useGlobalCompose(
69
+ accounts: RemitImapAccountResponse[],
70
+ ): () => void {
71
+ const { openCompose } = useCompose();
72
+ const navigate = useNavigate();
73
+ const location = useLocation();
74
+ const targetMailboxId = useComposeTargetMailboxId(accounts);
75
+
76
+ return useCallback(() => {
77
+ if (hostsComposeSurface(location.pathname)) {
78
+ startTransition(() => {
79
+ openCompose({ mode: "new" });
80
+ });
81
+ return;
82
+ }
83
+ if (!targetMailboxId) return;
84
+ startTransition(() => {
85
+ openCompose({ mode: "new" });
86
+ });
87
+ navigate({
88
+ to: "/mail/$mailboxId",
89
+ params: { mailboxId: targetMailboxId },
90
+ });
91
+ }, [openCompose, navigate, location.pathname, targetMailboxId]);
92
+ }
@@ -45,3 +45,29 @@ describe("isSinglePaneTier — compose surface must mount below desktop", () =>
45
45
  assert.equal(isSinglePaneTier(resolveLayoutTier(1024)), false);
46
46
  });
47
47
  });
48
+
49
+ describe("exactly one search field is mounted at every width", () => {
50
+ // Two call sites decide this from the same tier: `mail.tsx` mounts the top
51
+ // bar's field when the layout is not single-pane, and `MailListHeader`
52
+ // passes `showSearch` to keep the header's field otherwise. Both read
53
+ // `isSinglePaneTier`, so they are each other's complement by construction —
54
+ // if either is rewritten to its own expression this fails. Two mounted
55
+ // fields compete for "/" and for focus (#59); zero leaves no way to search.
56
+ const mountsTopBarField = (width: number): boolean =>
57
+ !isSinglePaneTier(resolveLayoutTier(width));
58
+ const mountsHeaderField = (width: number): boolean =>
59
+ isSinglePaneTier(resolveLayoutTier(width));
60
+
61
+ for (const width of [0, 390, 767, 768, 1023, 1024, 1440]) {
62
+ it(`mounts one field at ${width}px`, () => {
63
+ const fields =
64
+ Number(mountsTopBarField(width)) + Number(mountsHeaderField(width));
65
+ assert.equal(fields, 1, `${width}px mounts ${fields} search fields`);
66
+ });
67
+ }
68
+
69
+ it("puts the field in the top bar only from desktop up", () => {
70
+ assert.equal(mountsTopBarField(1023), false);
71
+ assert.equal(mountsTopBarField(1024), true);
72
+ });
73
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The one route resolver behind both compose entry points — the desktop top
3
+ * bar's button and the mobile `ComposeFab`. When they each carried their own
4
+ * copy the two disagreed, and the FAB opened compose state on routes that
5
+ * mount no surface: a dead button.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "node:test";
9
+ import { hostsComposeSurface } from "./compose-routes";
10
+
11
+ describe("hostsComposeSurface", () => {
12
+ it("is true for a mailbox route, which mounts FullCompose", () => {
13
+ assert.equal(hostsComposeSurface("/mail/INBOX"), true);
14
+ assert.equal(hostsComposeSurface("/mail/abc-123"), true);
15
+ });
16
+
17
+ it("is false for the virtual views, which mount no surface", () => {
18
+ assert.equal(hostsComposeSurface("/mail/outbox"), false);
19
+ assert.equal(hostsComposeSurface("/mail/flagged"), false);
20
+ });
21
+
22
+ it("is false for the daily brief, which is /mail itself", () => {
23
+ assert.equal(hostsComposeSurface("/mail"), false);
24
+ assert.equal(hostsComposeSurface("/mail/"), false);
25
+ });
26
+
27
+ it("is false outside the mail shell", () => {
28
+ assert.equal(hostsComposeSurface("/settings/accounts"), false);
29
+ assert.equal(hostsComposeSurface("/"), false);
30
+ assert.equal(hostsComposeSurface("/mailroom/x"), false);
31
+ });
32
+
33
+ it("matches whole segments, so a mailbox may be named after a view", () => {
34
+ assert.equal(hostsComposeSurface("/mail/outbox-2024"), true);
35
+ assert.equal(hostsComposeSurface("/mail/flagged-archive"), true);
36
+ assert.equal(hostsComposeSurface("/mail/outboxes"), true);
37
+ });
38
+
39
+ it("ignores a query string or hash on the path", () => {
40
+ assert.equal(hostsComposeSurface("/mail/INBOX?q=invoice"), true);
41
+ assert.equal(hostsComposeSurface("/mail/outbox?q=invoice"), false);
42
+ assert.equal(hostsComposeSurface("/mail/INBOX#top"), true);
43
+ });
44
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Which routes mount the compose surface.
3
+ *
4
+ * `FullCompose` is mounted by the mailbox route only, so compose started from
5
+ * anywhere else has to carry the user to a mailbox first. Kept apart from
6
+ * `useComposeTarget` so it stays a plain function with no React or API
7
+ * dependencies — both compose entry points resolve routes through this one
8
+ * definition, and a divergent second copy is what left the mobile FAB dead on
9
+ * `/mail/flagged` and on the brief.
10
+ */
11
+
12
+ /** `/mail/<segment>` values that name a view rather than a mailbox. */
13
+ const VIRTUAL_MAIL_VIEWS = new Set(["outbox", "flagged"]);
14
+
15
+ /**
16
+ * True for `/mail/<id>` where `<id>` is a real mailbox.
17
+ *
18
+ * Compares whole path segments: a mailbox genuinely named `outbox-2024` hosts
19
+ * the surface and must not be read as the virtual outbox.
20
+ */
21
+ export const hostsComposeSurface = (pathname: string): boolean => {
22
+ const [, root, view] = pathname.split(/[?#]/)[0].split("/");
23
+ if (root !== "mail" || !view) return false;
24
+ return !VIRTUAL_MAIL_VIEWS.has(view);
25
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `q` lives on the parent `/mail` route, but every child re-declares it: a
3
+ * child's `validateSearch` is authoritative for its own URL, so a child that
4
+ * omits `q` strips it. The top bar mounts a search field on all four of these
5
+ * routes, so a stripped `q` means typing a query does nothing and the query is
6
+ * lost on the next navigation.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import { z } from "zod";
11
+ import { Route as MailboxRoute } from "../routes/mail/$mailboxId";
12
+ import { Route as FlaggedRoute } from "../routes/mail/flagged";
13
+ import { Route as BriefRoute } from "../routes/mail/index";
14
+ import { Route as OutboxRoute } from "../routes/mail/outbox";
15
+
16
+ const routes = {
17
+ "/mail/ (daily brief)": BriefRoute,
18
+ "/mail/flagged": FlaggedRoute,
19
+ "/mail/outbox": OutboxRoute,
20
+ "/mail/$mailboxId": MailboxRoute,
21
+ };
22
+
23
+ const parse = (route: { options: { validateSearch?: unknown } }) => {
24
+ const schema = route.options.validateSearch;
25
+ assert.ok(
26
+ schema instanceof z.ZodType,
27
+ "route must validate its search with a zod schema",
28
+ );
29
+ return (search: Record<string, unknown>) => schema.parse(search);
30
+ };
31
+
32
+ describe("every /mail child route carries `q` through its own validation", () => {
33
+ for (const [name, route] of Object.entries(routes)) {
34
+ it(`${name} preserves a query`, () => {
35
+ const parsed = parse(route)({ q: "invoice" }) as { q?: string };
36
+ assert.equal(
37
+ parsed.q,
38
+ "invoice",
39
+ `${name} drops q, so the top bar's search field is inert there`,
40
+ );
41
+ });
42
+
43
+ it(`${name} leaves q absent when there is none`, () => {
44
+ const parsed = parse(route)({}) as { q?: string };
45
+ assert.equal(parsed.q, undefined);
46
+ });
47
+ }
48
+ });
@@ -3,8 +3,13 @@ import { z } from "zod";
3
3
 
4
4
  // Search schema: `selectedOutboxMessageId` is read by `OutboxPane` in the
5
5
  // parent `/mail` shell via `useSearch({ strict: false })`.
6
+ //
7
+ // `q` is inherited from the parent /mail route; re-declared here so it survives
8
+ // this route's own search validation and isn't dropped when navigating with a
9
+ // functional search updater.
6
10
  const outboxSearchSchema = z.object({
7
11
  selectedOutboxMessageId: z.string().optional(),
12
+ q: z.string().optional(),
8
13
  });
9
14
 
10
15
  export const Route = createFileRoute("/mail/outbox")({
@@ -15,6 +15,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
15
15
  import { z } from "zod";
16
16
  import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
17
17
  import { ComposeFab } from "@/components/layout/ComposeFab";
18
+ import { MailTopBar } from "@/components/layout/MailTopBar";
18
19
  import { BriefPane } from "@/components/mail/BriefPane";
19
20
  import { FlaggedPane } from "@/components/mail/FlaggedPane";
20
21
  import { MailboxPane } from "@/components/mail/MailboxPane";
@@ -268,7 +269,16 @@ function MailLayout() {
268
269
  const navContent = (
269
270
  <MailNav accounts={accounts} onMailboxSelect={handleMailboxSelect} />
270
271
  );
271
- const overlayContent = <ComposeFab />;
272
+ // Single-pane only, where the FAB is the compose entry point. Above it the
273
+ // top bar owns compose, and mounting the FAB there would resolve the same
274
+ // compose target twice for a button CSS keeps hidden.
275
+ const overlayContent = isSinglePane ? (
276
+ <ComposeFab accounts={accounts} />
277
+ ) : undefined;
278
+ // Desktop only. Below 1024px the single pane keeps its own header search and
279
+ // the phone takeover; there is no room for a bar spanning panes that do not
280
+ // exist side by side.
281
+ const topBar = isSinglePane ? undefined : <MailTopBar accounts={accounts} />;
272
282
  const navSlideOver = {
273
283
  navOpen: drawerOpen,
274
284
  onOpenNav: () => setDrawerOpen(true),
@@ -303,6 +313,7 @@ function MailLayout() {
303
313
  ) : (
304
314
  <AppShellSlotted
305
315
  nav={navContent}
316
+ topBar={topBar}
306
317
  list={<BriefPane.List />}
307
318
  reading={<BriefPane.Reading />}
308
319
  intelligenceOpen={intelligenceOpen}
@@ -330,6 +341,7 @@ function MailLayout() {
330
341
  ) : (
331
342
  <AppShellSlotted
332
343
  nav={navContent}
344
+ topBar={topBar}
333
345
  list={<FlaggedPane.List />}
334
346
  reading={<FlaggedPane.Reading />}
335
347
  intelligenceOpen={intelligenceOpen}
@@ -359,6 +371,7 @@ function MailLayout() {
359
371
  ) : (
360
372
  <AppShellSlotted
361
373
  nav={navContent}
374
+ topBar={topBar}
362
375
  list={<MailboxPane.List />}
363
376
  reading={<MailboxPane.Reading />}
364
377
  intelligence={<MailboxPane.Intelligence />}
@@ -386,6 +399,7 @@ function MailLayout() {
386
399
  ) : (
387
400
  <AppShellSlotted
388
401
  nav={navContent}
402
+ topBar={topBar}
389
403
  list={<OutboxPane.List />}
390
404
  reading={<OutboxPane.Reading />}
391
405
  intelligenceOpen={false}