@remit/web-client 0.0.124 → 0.0.126

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.124",
3
+ "version": "0.0.126",
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": {
@@ -58,7 +58,10 @@ interface BriefPaneContextValue {
58
58
  /** The conversation to open — the loaded thread, or a tapped "Related" hit. */
59
59
  conversation: ConversationTarget | undefined;
60
60
  onSelectMessage: (id: string, options?: OpenMessageOptions) => void;
61
- onSelectSearchResult: (result: SearchResult) => void;
61
+ onSelectSearchResult: (
62
+ result: SearchResult,
63
+ options?: OpenMessageOptions,
64
+ ) => void;
62
65
  onCloseThread: () => void;
63
66
  /**
64
67
  * Toolbar verbs for the open thread, keyed by the thread's own mailbox and
@@ -136,9 +139,10 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
136
139
  );
137
140
 
138
141
  const handleSelectSearchResult = useCallback(
139
- (result: SearchResult) => {
142
+ (result: SearchResult, options?: OpenMessageOptions) => {
140
143
  navigate({
141
144
  to: "/mail",
145
+ replace: options?.replace,
142
146
  search: (prev) => ({
143
147
  ...prev,
144
148
  // Commit the active query with the selection so the debounced
@@ -373,7 +373,10 @@ interface DailyBriefProps {
373
373
  * Opens a search result. A semantic "Related" hit carries its thread + mailbox
374
374
  * so it opens even when its message isn't in the loaded brief list.
375
375
  */
376
- onSelectSearchResult?: (result: SearchResult) => void;
376
+ onSelectSearchResult?: (
377
+ result: SearchResult,
378
+ options?: OpenMessageOptions,
379
+ ) => void;
377
380
  /** Where the list publishes the commands the keyboard layer drives. */
378
381
  commandsRef?: RefObject<MessageListCommands | null>;
379
382
  /** Cursor / selection / display order, reported up to the triage layer. */
@@ -561,6 +564,29 @@ export function DailyBrief({
561
564
  [bodyRows],
562
565
  );
563
566
 
567
+ // A committed query puts rows the widened cross-folder search found into the
568
+ // body, and those messages are in folders the brief itself never loads. The
569
+ // reading pane resolves a bare `selectedMessageId` against the brief's own
570
+ // thread list, so such a row opens nothing; it goes through the search path
571
+ // instead, which carries the thread and mailbox the conversation is fetched
572
+ // by (#635).
573
+ const openRow = useCallback(
574
+ (id: string, options?: OpenMessageOptions) => {
575
+ const row = sq
576
+ ? bodyRows.find((candidate) => candidate.id === id)
577
+ : undefined;
578
+ if (row?.threadId && row.mailboxId && onSelectSearchResult) {
579
+ onSelectSearchResult(
580
+ rowToSearchResult(row, resultFolderIndex),
581
+ options,
582
+ );
583
+ return;
584
+ }
585
+ onSelectMessage?.(id, options);
586
+ },
587
+ [sq, bodyRows, resultFolderIndex, onSelectSearchResult, onSelectMessage],
588
+ );
589
+
564
590
  const accountSources = useMemo<FilterSheetSource[]>(() => {
565
591
  if (nonMuted.length <= 1) return [];
566
592
  return [
@@ -750,7 +776,7 @@ export function DailyBrief({
750
776
  briefCategory={selectedCategory}
751
777
  Row={MessageRow}
752
778
  selectedThreadId={selectedMessageId}
753
- onSelectThread={onSelectMessage}
779
+ onSelectThread={openRow}
754
780
  onSelectBriefCategory={setSelectedCategory}
755
781
  sources={accountSources}
756
782
  sourcesNote={mutedCount > 0 ? `+${mutedCount} muted` : undefined}
@@ -798,7 +824,7 @@ export function DailyBrief({
798
824
  <FilterPanelProvider hasSheet={showsRows && !searching}>
799
825
  <ThreadListInteraction
800
826
  selectedMessageId={selectedMessageId}
801
- onOpen={(id, options) => onSelectMessage?.(id, options)}
827
+ onOpen={openRow}
802
828
  onDeleteMessages={onDeleteMessages}
803
829
  onSelectionVerb={wizard.start}
804
830
  wizardOpen={wizard.isOpen}
@@ -30,9 +30,15 @@ const mount = (
30
30
  disabledHint?: string;
31
31
  onMove?: (destinationMailboxId: string) => void;
32
32
  viewportWidth?: number;
33
+ orientation?: "portrait" | "landscape";
34
+ pointer?: "coarse" | "fine";
33
35
  } = {},
34
36
  ): DomHarness => {
35
- harness = createDomHarness({ viewportWidth: options.viewportWidth });
37
+ harness = createDomHarness({
38
+ viewportWidth: options.viewportWidth,
39
+ orientation: options.orientation,
40
+ pointer: options.pointer,
41
+ });
36
42
  if (options.mailboxes) {
37
43
  harness.queryClient.setQueryData(
38
44
  mailboxOperationsListMailboxesQueryKey({
@@ -171,6 +177,35 @@ describe("MoveToTrigger", () => {
171
177
  );
172
178
  });
173
179
 
180
+ it("opens a dialog on a tablet held upright, which is 1024px wide", () => {
181
+ // Regression: the desktop gate was a bare width, and a large tablet in
182
+ // portrait is exactly 1024px — it got the desktop popover on a touch
183
+ // screen with no room for it.
184
+ const dom = mount({
185
+ mailboxes: FOLDERS,
186
+ viewportWidth: 1024,
187
+ orientation: "portrait",
188
+ pointer: "coarse",
189
+ });
190
+ assert.equal(
191
+ dom.byLabel("Move to folder").getAttribute("aria-haspopup"),
192
+ "dialog",
193
+ );
194
+ });
195
+
196
+ it("opens a popover on the same tablet turned sideways", () => {
197
+ const dom = mount({
198
+ mailboxes: FOLDERS,
199
+ viewportWidth: 1024,
200
+ orientation: "landscape",
201
+ pointer: "coarse",
202
+ });
203
+ assert.notEqual(
204
+ dom.byLabel("Move to folder").getAttribute("aria-haspopup"),
205
+ "dialog",
206
+ );
207
+ });
208
+
174
209
  it("asks for the folder list only once the picker is opened", () => {
175
210
  const dom = mount();
176
211
  assert.equal(dom.queryAll("[role=treeitem]").length, 0);
@@ -1,23 +1,79 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
- import { isSinglePaneTier, resolveLayoutTier } from "./useLayoutTier";
3
+ import {
4
+ isSinglePaneTier,
5
+ resolveLayoutTier,
6
+ type ViewportProfile,
7
+ } from "./useLayoutTier";
8
+
9
+ const mouse = (width: number): ViewportProfile => ({
10
+ width,
11
+ orientation: "landscape",
12
+ pointer: "fine",
13
+ });
14
+
15
+ const tabletHeldUpright = (width: number): ViewportProfile => ({
16
+ width,
17
+ orientation: "portrait",
18
+ pointer: "coarse",
19
+ });
4
20
 
5
21
  describe("resolveLayoutTier breakpoints (#784)", () => {
6
22
  it("is phone below 768", () => {
7
- assert.equal(resolveLayoutTier(767), "phone");
8
- assert.equal(resolveLayoutTier(390), "phone");
9
- assert.equal(resolveLayoutTier(0), "phone");
23
+ assert.equal(resolveLayoutTier(mouse(767)), "phone");
24
+ assert.equal(resolveLayoutTier(mouse(390)), "phone");
25
+ assert.equal(resolveLayoutTier(mouse(0)), "phone");
10
26
  });
11
27
 
12
28
  it("is tablet from 768 up to 1023", () => {
13
- assert.equal(resolveLayoutTier(768), "tablet");
14
- assert.equal(resolveLayoutTier(900), "tablet");
15
- assert.equal(resolveLayoutTier(1023), "tablet");
29
+ assert.equal(resolveLayoutTier(mouse(768)), "tablet");
30
+ assert.equal(resolveLayoutTier(mouse(900)), "tablet");
31
+ assert.equal(resolveLayoutTier(mouse(1023)), "tablet");
16
32
  });
17
33
 
18
34
  it("is desktop from 1024 up", () => {
19
- assert.equal(resolveLayoutTier(1024), "desktop");
20
- assert.equal(resolveLayoutTier(1440), "desktop");
35
+ assert.equal(resolveLayoutTier(mouse(1024)), "desktop");
36
+ assert.equal(resolveLayoutTier(mouse(1440)), "desktop");
37
+ });
38
+ });
39
+
40
+ describe("a touch screen held upright is never desktop", () => {
41
+ // Regression: a large tablet is exactly 1024px wide in portrait, so a width
42
+ // threshold alone handed it the four-pane desktop shell. Turned sideways
43
+ // the same device is a desktop surface, and a tall monitor always is.
44
+ it("is tablet at 1024px in portrait on a touch screen", () => {
45
+ assert.equal(resolveLayoutTier(tabletHeldUpright(1024)), "tablet");
46
+ });
47
+
48
+ it("is tablet at wider portrait touch widths too", () => {
49
+ assert.equal(resolveLayoutTier(tabletHeldUpright(1366)), "tablet");
50
+ });
51
+
52
+ it("is desktop at 1024px on the same device turned sideways", () => {
53
+ assert.equal(
54
+ resolveLayoutTier({
55
+ width: 1024,
56
+ orientation: "landscape",
57
+ pointer: "coarse",
58
+ }),
59
+ "desktop",
60
+ );
61
+ });
62
+
63
+ it("is desktop on a portrait monitor, which has a fine pointer", () => {
64
+ assert.equal(
65
+ resolveLayoutTier({
66
+ width: 1200,
67
+ orientation: "portrait",
68
+ pointer: "fine",
69
+ }),
70
+ "desktop",
71
+ );
72
+ });
73
+
74
+ it("keeps the phone/tablet split on width alone", () => {
75
+ assert.equal(resolveLayoutTier(tabletHeldUpright(767)), "phone");
76
+ assert.equal(resolveLayoutTier(tabletHeldUpright(768)), "tablet");
21
77
  });
22
78
  });
23
79
 
@@ -39,10 +95,14 @@ describe("isSinglePaneTier — compose surface must mount below desktop", () =>
39
95
  });
40
96
 
41
97
  it("treats every below-desktop width as single-pane via resolveLayoutTier", () => {
42
- assert.equal(isSinglePaneTier(resolveLayoutTier(390)), true);
43
- assert.equal(isSinglePaneTier(resolveLayoutTier(768)), true);
44
- assert.equal(isSinglePaneTier(resolveLayoutTier(1023)), true);
45
- assert.equal(isSinglePaneTier(resolveLayoutTier(1024)), false);
98
+ assert.equal(isSinglePaneTier(resolveLayoutTier(mouse(390))), true);
99
+ assert.equal(isSinglePaneTier(resolveLayoutTier(mouse(768))), true);
100
+ assert.equal(isSinglePaneTier(resolveLayoutTier(mouse(1023))), true);
101
+ assert.equal(isSinglePaneTier(resolveLayoutTier(mouse(1024))), false);
102
+ assert.equal(
103
+ isSinglePaneTier(resolveLayoutTier(tabletHeldUpright(1024))),
104
+ true,
105
+ );
46
106
  });
47
107
  });
48
108
 
@@ -53,21 +113,32 @@ describe("exactly one search field is mounted at every width", () => {
53
113
  // `isSinglePaneTier`, so they are each other's complement by construction —
54
114
  // if either is rewritten to its own expression this fails. Two mounted
55
115
  // 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));
116
+ const mountsTopBarField = (viewport: ViewportProfile): boolean =>
117
+ !isSinglePaneTier(resolveLayoutTier(viewport));
118
+ const mountsHeaderField = (viewport: ViewportProfile): boolean =>
119
+ isSinglePaneTier(resolveLayoutTier(viewport));
60
120
 
61
- for (const width of [0, 390, 767, 768, 1023, 1024, 1440]) {
62
- it(`mounts one field at ${width}px`, () => {
121
+ for (const viewport of [
122
+ mouse(0),
123
+ mouse(390),
124
+ mouse(767),
125
+ mouse(768),
126
+ mouse(1023),
127
+ mouse(1024),
128
+ mouse(1440),
129
+ tabletHeldUpright(1024),
130
+ ]) {
131
+ it(`mounts one field at ${viewport.width}px ${viewport.orientation}/${viewport.pointer}`, () => {
63
132
  const fields =
64
- Number(mountsTopBarField(width)) + Number(mountsHeaderField(width));
65
- assert.equal(fields, 1, `${width}px mounts ${fields} search fields`);
133
+ Number(mountsTopBarField(viewport)) +
134
+ Number(mountsHeaderField(viewport));
135
+ assert.equal(fields, 1, `${viewport.width}px mounts ${fields} fields`);
66
136
  });
67
137
  }
68
138
 
69
139
  it("puts the field in the top bar only from desktop up", () => {
70
- assert.equal(mountsTopBarField(1023), false);
71
- assert.equal(mountsTopBarField(1024), true);
140
+ assert.equal(mountsTopBarField(mouse(1023)), false);
141
+ assert.equal(mountsTopBarField(mouse(1024)), true);
142
+ assert.equal(mountsTopBarField(tabletHeldUpright(1024)), false);
72
143
  });
73
144
  });
@@ -1,32 +1,45 @@
1
+ import { DESKTOP_MEDIA_QUERY, DESKTOP_MIN_WIDTH } from "@remit/ui";
1
2
  import { useMediaQuery } from "./useMediaQuery";
2
3
 
3
4
  /**
4
5
  * Responsive layout tiers for the mail shell:
5
6
  * - `phone` (< 768px) single pane: list → reading swaps in place.
6
- * - `tablet` (768–1023px) two panes: list + reading, nav rail is drawer-backed.
7
- * - `desktop` (>= 1024px) full four-pane shell (nav · list · reading · pane 4).
7
+ * - `tablet` (768–1023px, or any width on a touch screen held upright)
8
+ * single pane, nav rail is drawer-backed.
9
+ * - `desktop` (>= 1024px, not portrait-on-touch)
10
+ * full four-pane shell (nav · list · reading · pane 4).
8
11
  *
9
12
  * Pane 4 (intelligence) is desktop-only; `useIsDesktop` stays the pane-4 gate.
10
13
  */
11
14
  export type LayoutTier = "phone" | "tablet" | "desktop";
12
15
 
13
16
  export const TABLET_MIN_WIDTH = 768;
14
- export const DESKTOP_MIN_WIDTH = 1024;
15
17
 
16
- /** Pure breakpoint resolution, so the tier logic is testable without a DOM. */
17
- export const resolveLayoutTier = (width: number): LayoutTier => {
18
- if (width >= DESKTOP_MIN_WIDTH) return "desktop";
18
+ export interface ViewportProfile {
19
+ width: number;
20
+ orientation: "portrait" | "landscape";
21
+ pointer: "coarse" | "fine";
22
+ }
23
+
24
+ /** Pure tier resolution, so the logic is testable without a DOM. */
25
+ export const resolveLayoutTier = ({
26
+ width,
27
+ orientation,
28
+ pointer,
29
+ }: ViewportProfile): LayoutTier => {
30
+ const heldUpright = orientation === "portrait" && pointer === "coarse";
31
+ if (width >= DESKTOP_MIN_WIDTH && !heldUpright) return "desktop";
19
32
  if (width >= TABLET_MIN_WIDTH) return "tablet";
20
33
  return "phone";
21
34
  };
22
35
 
23
36
  /**
24
37
  * Whether the mail shell shows a SINGLE pane (no reading pane) at this tier.
25
- * AppShellSlotted mounts the reading pane only at desktop (≥1024px), so both
26
- * phone AND tablet are single-pane: the one pane swaps in place between the
27
- * list, an open thread, and the compose surface. Compose therefore must mount
28
- * via the single-pane view at tablet too — keying the pane choice off "phone"
29
- * alone left tablet with no compose surface ("c" / FAB opened nothing).
38
+ * AppShellSlotted mounts the reading pane only at desktop, so both phone AND
39
+ * tablet are single-pane: the one pane swaps in place between the list, an
40
+ * open thread, and the compose surface. Compose therefore must mount via the
41
+ * single-pane view at tablet too — keying the pane choice off "phone" alone
42
+ * left tablet with no compose surface ("c" / FAB opened nothing).
30
43
  */
31
44
  export const isSinglePaneTier = (tier: LayoutTier): boolean =>
32
45
  tier !== "desktop";
@@ -37,7 +50,7 @@ export const isSinglePaneTier = (tier: LayoutTier): boolean =>
37
50
  */
38
51
  export const useLayoutTier = (): LayoutTier => {
39
52
  const isTabletUp = useMediaQuery(`(min-width: ${TABLET_MIN_WIDTH}px)`);
40
- const isDesktop = useMediaQuery(`(min-width: ${DESKTOP_MIN_WIDTH}px)`);
53
+ const isDesktop = useMediaQuery(DESKTOP_MEDIA_QUERY);
41
54
  if (isDesktop) return "desktop";
42
55
  if (isTabletUp) return "tablet";
43
56
  return "phone";
@@ -1,12 +1,12 @@
1
+ import { DESKTOP_MEDIA_QUERY } from "@remit/ui";
1
2
  import { useEffect, useState } from "react";
2
3
 
3
4
  /**
4
5
  * Subscribes to a CSS media query and returns its current `matches` value.
5
6
  * SSR-safe: returns `false` on the server / initial render before hydration.
6
7
  *
7
- * Common breakpoint shortcuts (mobile-first):
8
- * useMediaQuery("(min-width: 768px)") // Tailwind md and up
9
- * useMediaQuery("(min-width: 1024px)") // Tailwind lg and up
8
+ * Layout breakpoints are exported by `@remit/ui` alongside the Tailwind
9
+ * variants they mirror pass those, not a hand-written width.
10
10
  */
11
11
  export const useMediaQuery = (query: string): boolean => {
12
12
  const [matches, setMatches] = useState(() => {
@@ -28,12 +28,13 @@ export const useMediaQuery = (query: string): boolean => {
28
28
  };
29
29
 
30
30
  /**
31
- * True at desktop widths (Tailwind `lg:` and up, 1024px). Below this the app
32
- * renders the single-pane mobile layout: tablet portrait (768px) and phones
33
- * both fall under here, so they get the stacked list reading flow with
34
- * drawer chrome instead of the cramped three-pane desktop grid (#682).
31
+ * True at desktop (Tailwind `lg:` and up): at least 1024px wide and not a
32
+ * touch screen held upright. Everything else renders the single-pane mobile
33
+ * layout phones, narrow tablets, and a large tablet in portrait, which is
34
+ * 1024px wide but has no room for the three-pane desktop grid (#682).
35
35
  *
36
- * The CSS-gated mobile chrome (Drawer, ComposeFab) uses `lg:hidden` to match
37
- * this boundary keep them in sync if the breakpoint changes.
36
+ * The CSS-gated mobile chrome (Drawer, ComposeFab) uses `lg:hidden`, and the
37
+ * `lg` variant is redefined in `@remit/ui`'s token sheet with the same
38
+ * condition — change `DESKTOP_MEDIA_QUERY` and both move together.
38
39
  */
39
- export const useIsDesktop = (): boolean => useMediaQuery("(min-width: 1024px)");
40
+ export const useIsDesktop = (): boolean => useMediaQuery(DESKTOP_MEDIA_QUERY);
package/src/lib/brief.ts CHANGED
@@ -56,6 +56,7 @@ export function toThreadRowData(
56
56
  id: thread.messageId,
57
57
  accountId: thread.accountId,
58
58
  mailboxId: thread.mailboxId,
59
+ threadId: thread.threadId,
59
60
  fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
60
61
  fromEmail: thread.fromEmail ?? "",
61
62
  subject: thread.subject ?? "(No subject)",
@@ -0,0 +1,84 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, resolve } from "node:path";
5
+ import { describe, it } from "node:test";
6
+ import { fileURLToPath } from "node:url";
7
+ import { DESKTOP_MEDIA_QUERY } from "@remit/ui";
8
+ import { compile } from "tailwindcss";
9
+
10
+ const here = dirname(fileURLToPath(import.meta.url));
11
+
12
+ const entryStylesheets = {
13
+ app: resolve(here, "../index.css"),
14
+ workbench: resolve(here, "../../../workbench/.storybook/tailwind.css"),
15
+ };
16
+
17
+ const loadStylesheet = async (id: string, base: string) => {
18
+ const require = createRequire(resolve(base, "resolve-from-here.js"));
19
+ const path =
20
+ id.startsWith(".") || id.startsWith("/")
21
+ ? resolve(base, id)
22
+ : resolveCssExport(require, id);
23
+ return { path, base: dirname(path), content: await readFile(path, "utf8") };
24
+ };
25
+
26
+ const resolveCssExport = (require: NodeRequire, id: string): string => {
27
+ const direct = tryResolve(require, id);
28
+ if (direct?.endsWith(".css")) return direct;
29
+ return require.resolve(`${id}/index.css`);
30
+ };
31
+
32
+ const tryResolve = (require: NodeRequire, id: string): string | undefined => {
33
+ try {
34
+ return require.resolve(id);
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ };
39
+
40
+ /**
41
+ * The media condition Tailwind wraps a utility in, read back off the compiled
42
+ * stylesheet. Building one candidate at a time keeps a single rule inside the
43
+ * block, so the prelude immediately precedes it.
44
+ */
45
+ const compiledMediaConditionFor = async (
46
+ stylesheet: string,
47
+ utility: string,
48
+ ): Promise<string> => {
49
+ const compiler = await compile(await readFile(stylesheet, "utf8"), {
50
+ base: dirname(stylesheet),
51
+ loadStylesheet,
52
+ });
53
+ const css = compiler.build([utility]);
54
+ const escaped = utility.replace(/:/g, "\\\\:");
55
+ const match = css.match(
56
+ new RegExp(`@media ([^\\n{]+?)\\s*\\{\\s*\\.${escaped}\\s*\\{`),
57
+ );
58
+ assert.ok(match, `${utility} emitted no media-wrapped rule in ${stylesheet}`);
59
+ return match[1];
60
+ };
61
+
62
+ describe("the lg variant and the desktop hooks gate on the same condition", () => {
63
+ // Regression: the desktop gate used to be a bare 1024px width, so a large
64
+ // tablet in portrait — exactly 1024px wide — got the four-pane desktop
65
+ // shell. Two systems encode the gate: DESKTOP_MEDIA_QUERY drives the JS
66
+ // hooks, and Tailwind's `lg` variant (redefined in @remit/ui's token sheet)
67
+ // drives the CSS chrome. If either moves alone the app splits in half.
68
+ for (const [name, stylesheet] of Object.entries(entryStylesheets)) {
69
+ it(`compiles lg:hidden in the ${name} stylesheet to DESKTOP_MEDIA_QUERY`, async () => {
70
+ assert.equal(
71
+ await compiledMediaConditionFor(stylesheet, "lg:hidden"),
72
+ DESKTOP_MEDIA_QUERY,
73
+ );
74
+ });
75
+ }
76
+
77
+ it("excludes a coarse pointer held upright", () => {
78
+ assert.match(DESKTOP_MEDIA_QUERY, /min-width: 1024px/);
79
+ assert.match(
80
+ DESKTOP_MEDIA_QUERY,
81
+ /not \(\(orientation: portrait\) and \(pointer: coarse\)\)/,
82
+ );
83
+ });
84
+ });
@@ -73,6 +73,7 @@ export function rowToSearchResult(
73
73
  date: row.timeLabel,
74
74
  unread: !row.isRead,
75
75
  flagged: row.starred === true,
76
+ ...(row.threadId ? { threadId: row.threadId } : {}),
76
77
  ...(row.mailboxId ? { mailboxId: row.mailboxId } : {}),
77
78
  ...(folder ? { folder } : {}),
78
79
  };
@@ -9,10 +9,7 @@
9
9
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
10
10
  import React, { act, createElement } from "react";
11
11
  import { createRoot, type Root } from "react-dom/client";
12
- import {
13
- DEFAULT_VIEWPORT_WIDTH,
14
- setViewportWidth,
15
- } from "../../test-support/dom-env.mjs";
12
+ import { DEFAULT_VIEWPORT, setViewport } from "../../test-support/dom-env.mjs";
16
13
  import { ErrorBannerProvider } from "../components/ui/ErrorBannerProvider";
17
14
 
18
15
  export interface DomHarness {
@@ -46,10 +43,17 @@ export interface DomHarness {
46
43
  export interface DomOptions {
47
44
  /** Width `matchMedia` answers against — jsdom has no layout of its own. */
48
45
  viewportWidth?: number;
46
+ /** Screen posture `matchMedia` answers against — jsdom has no device. */
47
+ orientation?: "portrait" | "landscape";
48
+ pointer?: "coarse" | "fine";
49
49
  }
50
50
 
51
51
  export const createDomHarness = (options: DomOptions = {}): DomHarness => {
52
- setViewportWidth(options.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH);
52
+ setViewport({
53
+ width: options.viewportWidth ?? DEFAULT_VIEWPORT.width,
54
+ orientation: options.orientation ?? DEFAULT_VIEWPORT.orientation,
55
+ pointer: options.pointer ?? DEFAULT_VIEWPORT.pointer,
56
+ });
53
57
  // The test loader transpiles remit-ui's `.tsx` with the classic JSX runtime,
54
58
  // which reads a global `React`. Vite uses the automatic runtime, so this
55
59
  // shim exists only for the test harness.
@@ -106,7 +110,7 @@ export const createDomHarness = (options: DomOptions = {}): DomHarness => {
106
110
  // which otherwise holds the test process open for its full gcTime.
107
111
  queryClient.clear();
108
112
  container.remove();
109
- setViewportWidth(DEFAULT_VIEWPORT_WIDTH);
113
+ setViewport(DEFAULT_VIEWPORT);
110
114
  },
111
115
  html: () => container.innerHTML,
112
116
  text: () => container.textContent ?? "",