@remit/web-client 0.0.125 → 0.0.127

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.
@@ -4,6 +4,21 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Remit</title>
7
+ <!--
8
+ Installability. This is the template the shipped image builds from; the
9
+ package's top-level index.html is the dev server's and carries the same
10
+ tags. The two theme colours are the light and dark --canvas tokens from
11
+ packages/ui/src/tokens.css.
12
+ -->
13
+ <link rel="manifest" href="/manifest.webmanifest" />
14
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
15
+ <link rel="icon" href="/favicon.ico" sizes="16x16 32x32" />
16
+ <link rel="icon" href="/icons/icon-192.png" type="image/png" sizes="192x192" />
17
+ <meta name="apple-mobile-web-app-capable" content="yes" />
18
+ <meta name="apple-mobile-web-app-title" content="Remit" />
19
+ <meta name="apple-mobile-web-app-status-bar-style" content="default" />
20
+ <meta name="theme-color" media="(prefers-color-scheme: light)" content="#f6f2e7" />
21
+ <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0c1d23" />
7
22
  <!--
8
23
  Pre-paint theme to avoid a flash-of-wrong-theme on reload. Mirrors
9
24
  theme-preference.ts: a stored "remit.theme" of "light"/"dark" wins;
package/index.html CHANGED
@@ -5,13 +5,20 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Remit</title>
7
7
  <!--
8
- Hint the browser to start the TLS handshake to API Gateway in parallel
9
- with the JS bundle download. Saves ~150-300ms before the first /config
10
- request fires. Hardcoded to the prod API origin; in local dev this
11
- preconnect is harmless (it's just a hint and the dev API is proxied
12
- through the Vite server).
8
+ Installability. Keep in sync with harness/index.html, which is the
9
+ template the shipped image builds from: this one is the dev server's.
10
+ The two theme colours are the light and dark --canvas tokens from
11
+ packages/ui/src/tokens.css.
13
12
  -->
14
- <link rel="preconnect" href="https://cr0f00ych7.execute-api.eu-west-1.amazonaws.com" crossorigin />
13
+ <link rel="manifest" href="/manifest.webmanifest" />
14
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
15
+ <link rel="icon" href="/favicon.ico" sizes="16x16 32x32" />
16
+ <link rel="icon" href="/icons/icon-192.png" type="image/png" sizes="192x192" />
17
+ <meta name="apple-mobile-web-app-capable" content="yes" />
18
+ <meta name="apple-mobile-web-app-title" content="Remit" />
19
+ <meta name="apple-mobile-web-app-status-bar-style" content="default" />
20
+ <meta name="theme-color" media="(prefers-color-scheme: light)" content="#f6f2e7" />
21
+ <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0c1d23" />
15
22
  <!--
16
23
  Pre-paint theme to avoid a flash-of-wrong-theme on reload. Mirrors
17
24
  theme-preference.ts: a stored "remit.theme" of "light"/"dark" wins;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.125",
3
+ "version": "0.0.127",
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": {
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,34 @@
1
+ {
2
+ "id": "/",
3
+ "name": "Remit",
4
+ "short_name": "Remit",
5
+ "description": "An email client you run yourself.",
6
+ "start_url": "/",
7
+ "scope": "/",
8
+ "display": "standalone",
9
+ "lang": "en",
10
+ "dir": "ltr",
11
+ "categories": ["productivity"],
12
+ "background_color": "#f5f5bd",
13
+ "theme_color": "#0c1d23",
14
+ "icons": [
15
+ {
16
+ "src": "/icons/icon-192.png",
17
+ "sizes": "192x192",
18
+ "type": "image/png",
19
+ "purpose": "any"
20
+ },
21
+ {
22
+ "src": "/icons/icon-512.png",
23
+ "sizes": "512x512",
24
+ "type": "image/png",
25
+ "purpose": "any"
26
+ },
27
+ {
28
+ "src": "/icons/icon-maskable-512.png",
29
+ "sizes": "512x512",
30
+ "type": "image/png",
31
+ "purpose": "maskable"
32
+ }
33
+ ]
34
+ }
@@ -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);
@@ -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
+ });
@@ -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 ?? "",