@remit/ui 0.0.102 → 0.0.104

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.
@@ -0,0 +1,147 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { expect, userEvent, within } from "storybook/test";
4
+ import {
5
+ type AddressEntry,
6
+ ComposeAddressField,
7
+ } from "./compose-address-field.js";
8
+ import { ComposeHeader, composeHeaderSummary } from "./compose-header.js";
9
+ import { ComposeSubjectField } from "./compose-subject-field.js";
10
+
11
+ const FromRow = ({ email }: { email: string }) => (
12
+ <div className="flex items-start gap-2">
13
+ {/* biome-ignore lint/a11y/noLabelWithoutControl: decorative label for a read-only value, not a form control */}
14
+ <label className="text-sm text-fg-muted shrink-0 w-12 pt-1.5">From:</label>
15
+ <div className="text-sm py-1.5">{email}</div>
16
+ </div>
17
+ );
18
+
19
+ /**
20
+ * Who the message is going to and what it is about. Cc and Bcc are not there
21
+ * until they are asked for — the common message has one recipient line, and
22
+ * three empty ones push the writing surface off a phone.
23
+ */
24
+ const meta: Meta<typeof ComposeHeader> = {
25
+ title: "Mail/ComposeHeader",
26
+ component: ComposeHeader,
27
+ parameters: { layout: "padded" },
28
+ };
29
+ export default meta;
30
+
31
+ type Story = StoryObj<typeof ComposeHeader>;
32
+
33
+ const Harness = ({
34
+ initialTo = [],
35
+ initialCc,
36
+ initialBcc,
37
+ initialSubject = "",
38
+ collapsed = false,
39
+ }: {
40
+ initialTo?: AddressEntry[];
41
+ initialCc?: AddressEntry[];
42
+ initialBcc?: AddressEntry[];
43
+ initialSubject?: string;
44
+ collapsed?: boolean;
45
+ }) => {
46
+ const [to, setTo] = useState(initialTo);
47
+ const [cc, setCc] = useState(initialCc ?? []);
48
+ const [bcc, setBcc] = useState(initialBcc ?? []);
49
+ const [showCc, setShowCc] = useState(initialCc !== undefined);
50
+ const [showBcc, setShowBcc] = useState(initialBcc !== undefined);
51
+ const [subject, setSubject] = useState(initialSubject);
52
+
53
+ return (
54
+ <div className="w-[560px] border border-line bg-surface">
55
+ <ComposeHeader
56
+ collapsed={collapsed}
57
+ summary={composeHeaderSummary({ to, cc, bcc, subject })}
58
+ from={<FromRow email="alice@northwind.example" />}
59
+ to={
60
+ <ComposeAddressField
61
+ label="To"
62
+ addresses={to}
63
+ onChange={setTo}
64
+ placeholder="Recipients"
65
+ />
66
+ }
67
+ cc={
68
+ showCc ? (
69
+ <ComposeAddressField label="Cc" addresses={cc} onChange={setCc} />
70
+ ) : undefined
71
+ }
72
+ bcc={
73
+ showBcc ? (
74
+ <ComposeAddressField
75
+ label="Bcc"
76
+ addresses={bcc}
77
+ onChange={setBcc}
78
+ />
79
+ ) : undefined
80
+ }
81
+ subject={<ComposeSubjectField value={subject} onChange={setSubject} />}
82
+ onShowCc={() => setShowCc(true)}
83
+ onShowBcc={() => setShowBcc(true)}
84
+ />
85
+ </div>
86
+ );
87
+ };
88
+
89
+ export const NewMessage: Story = {
90
+ name: "New message — nothing filled in",
91
+ render: () => <Harness />,
92
+ };
93
+
94
+ export const Reply: Story = {
95
+ render: () => (
96
+ <Harness
97
+ initialTo={[{ email: "ada@northwind.example", displayName: "Ada" }]}
98
+ initialSubject="Re: Q3 planning"
99
+ />
100
+ ),
101
+ };
102
+
103
+ export const CcAndBccRevealed: Story = {
104
+ render: () => (
105
+ <Harness
106
+ initialTo={[{ email: "ada@northwind.example", displayName: "Ada" }]}
107
+ initialCc={[{ email: "grace@northwind.example" }]}
108
+ initialBcc={[]}
109
+ initialSubject="Re: Q3 planning"
110
+ />
111
+ ),
112
+ };
113
+
114
+ /**
115
+ * The software keyboard is up on a phone. The header gives its space to the
116
+ * writing surface and keeps one line of what is already filled in.
117
+ */
118
+ export const Collapsed: Story = {
119
+ render: () => (
120
+ <Harness
121
+ collapsed
122
+ initialTo={[
123
+ { email: "ada@northwind.example", displayName: "Ada Lovelace" },
124
+ ]}
125
+ initialCc={[{ email: "grace@northwind.example" }]}
126
+ initialSubject="Re: Q3 planning"
127
+ />
128
+ ),
129
+ };
130
+
131
+ /** Collapsed before anything is typed: an ellipsis, not an empty bar. */
132
+ export const CollapsedAndEmpty: Story = {
133
+ render: () => <Harness collapsed />,
134
+ };
135
+
136
+ export const RevealingCcLeavesBccOnOffer: Story = {
137
+ render: () => <Harness />,
138
+ play: async ({ canvasElement }) => {
139
+ const canvas = within(canvasElement);
140
+ await expect(canvas.queryByLabelText("Cc:")).not.toBeInTheDocument();
141
+ await userEvent.click(canvas.getByRole("button", { name: "Cc" }));
142
+ await expect(canvas.getByLabelText("Cc:")).toBeVisible();
143
+ await expect(canvas.queryByLabelText("Bcc:")).not.toBeInTheDocument();
144
+ await userEvent.click(canvas.getByRole("button", { name: "Bcc" }));
145
+ await expect(canvas.getByLabelText("Bcc:")).toBeVisible();
146
+ },
147
+ };
@@ -0,0 +1,109 @@
1
+ import type { ReactNode } from "react";
2
+ import type { AddressEntry } from "./compose-address-field.js";
3
+
4
+ export interface ComposeHeaderProps {
5
+ /**
6
+ * The software keyboard is up on a phone and the recipient rows would eat
7
+ * the writing surface. The header collapses to one line of what is already
8
+ * filled in.
9
+ */
10
+ collapsed?: boolean;
11
+ /** One line of what the collapsed header stands in for. */
12
+ summary?: string;
13
+ from: ReactNode;
14
+ to: ReactNode;
15
+ /** Present once the field has been revealed; absent leaves the reveal button. */
16
+ cc?: ReactNode;
17
+ bcc?: ReactNode;
18
+ subject: ReactNode;
19
+ onShowCc: () => void;
20
+ onShowBcc: () => void;
21
+ }
22
+
23
+ const RevealButton = ({
24
+ label,
25
+ onClick,
26
+ }: {
27
+ label: string;
28
+ onClick: () => void;
29
+ }) => (
30
+ <button
31
+ type="button"
32
+ onClick={onClick}
33
+ className="text-xs text-fg-muted hover:text-fg transition-colors"
34
+ >
35
+ {label}
36
+ </button>
37
+ );
38
+
39
+ export const composeHeaderSummary = ({
40
+ to,
41
+ cc,
42
+ bcc,
43
+ subject,
44
+ }: {
45
+ to: readonly AddressEntry[];
46
+ cc: readonly AddressEntry[];
47
+ bcc: readonly AddressEntry[];
48
+ subject: string;
49
+ }): string => {
50
+ const chips: string[] = [];
51
+ if (to.length > 0)
52
+ chips.push(`To: ${to.map((a) => a.displayName ?? a.email).join(", ")}`);
53
+ if (cc.length > 0) chips.push(`Cc: ${cc.length}`);
54
+ if (bcc.length > 0) chips.push(`Bcc: ${bcc.length}`);
55
+ if (subject) chips.push(subject);
56
+ return chips.join(" · ");
57
+ };
58
+
59
+ /**
60
+ * Who the message is going to and what it is about. The fields are slots: the
61
+ * app fills them with the wired address and account controls, a story with
62
+ * static ones, and both get the same layout, the same Cc/Bcc reveal and the
63
+ * same collapsed line.
64
+ */
65
+ export const ComposeHeader = ({
66
+ collapsed = false,
67
+ summary = "",
68
+ from,
69
+ to,
70
+ cc,
71
+ bcc,
72
+ subject,
73
+ onShowCc,
74
+ onShowBcc,
75
+ }: ComposeHeaderProps) => {
76
+ if (collapsed) {
77
+ return (
78
+ <div
79
+ className="flex items-center gap-2 px-3 py-1.5 border-b border-line overflow-hidden"
80
+ data-testid="compose-header-collapsed"
81
+ >
82
+ <span className="truncate text-xs text-fg-muted">{summary || "…"}</span>
83
+ <span className="shrink-0 inline-flex items-center justify-center rounded bg-surface-sunken px-1.5 py-0.5 text-2xs text-fg-muted">
84
+
85
+ </span>
86
+ </div>
87
+ );
88
+ }
89
+
90
+ return (
91
+ <div className="space-y-1 px-3 py-2 border-b border-line">
92
+ {from}
93
+ {to}
94
+ {cc ?? (
95
+ <div className="flex gap-2 pl-14">
96
+ <RevealButton label="Cc" onClick={onShowCc} />
97
+ <RevealButton label="Bcc" onClick={onShowBcc} />
98
+ </div>
99
+ )}
100
+ {cc && !bcc && (
101
+ <div className="pl-14">
102
+ <RevealButton label="Bcc" onClick={onShowBcc} />
103
+ </div>
104
+ )}
105
+ {bcc}
106
+ {subject}
107
+ </div>
108
+ );
109
+ };
@@ -0,0 +1,53 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { expect, userEvent, within } from "storybook/test";
4
+ import {
5
+ type ComposeBodyMode,
6
+ ComposeModeToggle,
7
+ } from "./compose-mode-toggle.js";
8
+
9
+ /**
10
+ * The control that swaps the writing surface. It reads "Plain text" in both
11
+ * states — the label names the mode it offers, and `aria-pressed` carries
12
+ * which one is up, so the control never changes under the finger.
13
+ */
14
+ const meta: Meta<typeof ComposeModeToggle> = {
15
+ title: "Mail/ComposeModeToggle",
16
+ component: ComposeModeToggle,
17
+ parameters: { layout: "centered" },
18
+ args: { onToggle: () => undefined },
19
+ };
20
+ export default meta;
21
+
22
+ type Story = StoryObj<typeof ComposeModeToggle>;
23
+
24
+ export const RichText: Story = {
25
+ name: "Rich text — plain text on offer",
26
+ args: { mode: "rich" },
27
+ };
28
+
29
+ export const PlainText: Story = {
30
+ name: "Plain text — the mode is on",
31
+ args: { mode: "plain" },
32
+ };
33
+
34
+ const Harness = ({ start }: { start: ComposeBodyMode }) => {
35
+ const [mode, setMode] = useState<ComposeBodyMode>(start);
36
+ return (
37
+ <ComposeModeToggle
38
+ mode={mode}
39
+ onToggle={() => setMode(mode === "plain" ? "rich" : "plain")}
40
+ />
41
+ );
42
+ };
43
+
44
+ export const PressedStateFollowsTheMode: Story = {
45
+ render: () => <Harness start="rich" />,
46
+ play: async ({ canvasElement }) => {
47
+ const toggle = within(canvasElement).getByTestId("compose-mode-toggle");
48
+ await expect(toggle).toHaveAttribute("aria-pressed", "false");
49
+ await userEvent.click(toggle);
50
+ await expect(toggle).toHaveAttribute("aria-pressed", "true");
51
+ await expect(toggle).toHaveTextContent("Plain text");
52
+ },
53
+ };
@@ -0,0 +1,30 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { expect, fn, userEvent, within } from "storybook/test";
3
+ import { ComposeSmtpMissingBanner } from "./compose-smtp-missing-banner.js";
4
+
5
+ /**
6
+ * The selected account has no SMTP host. Send stays pressable and explains
7
+ * itself; this says the same thing before the user reaches for it, and carries
8
+ * the way out rather than leaving them to find Settings.
9
+ */
10
+ const meta: Meta<typeof ComposeSmtpMissingBanner> = {
11
+ title: "Mail/ComposeSmtpMissingBanner",
12
+ component: ComposeSmtpMissingBanner,
13
+ parameters: { layout: "padded" },
14
+ args: { onConfigure: () => undefined },
15
+ };
16
+ export default meta;
17
+
18
+ type Story = StoryObj<typeof ComposeSmtpMissingBanner>;
19
+
20
+ export const Default: Story = {};
21
+
22
+ export const TheWayOutIsReachable: Story = {
23
+ args: { onConfigure: fn() },
24
+ play: async ({ args, canvasElement }) => {
25
+ await userEvent.click(
26
+ within(canvasElement).getByRole("button", { name: /Configure SMTP/ }),
27
+ );
28
+ await expect(args.onConfigure).toHaveBeenCalled();
29
+ },
30
+ };
@@ -0,0 +1,39 @@
1
+ import { AlertTriangle, ArrowRight } from "lucide-react";
2
+
3
+ export interface ComposeSmtpMissingBannerProps {
4
+ onConfigure: () => void;
5
+ }
6
+
7
+ /**
8
+ * Non-dismissible banner shown above the compose form when the selected
9
+ * account has no SMTP host configured. Pairs with a Send button that explains
10
+ * rather than disables, so the user has a single, factual account of why
11
+ * nothing will leave. See issue #196.
12
+ */
13
+ export const ComposeSmtpMissingBanner = ({
14
+ onConfigure,
15
+ }: ComposeSmtpMissingBannerProps) => (
16
+ <div
17
+ role="alert"
18
+ data-testid="compose-smtp-missing-banner"
19
+ className="flex items-start gap-3 border-b border-warning/50 bg-warning/10 px-3 py-2"
20
+ >
21
+ <AlertTriangle
22
+ className="size-5 shrink-0 mt-0.5 text-warning"
23
+ aria-hidden="true"
24
+ />
25
+ <div className="flex-1 min-w-0">
26
+ <p className="text-sm font-medium text-warning">
27
+ This account can't send mail until SMTP is configured.
28
+ </p>
29
+ <button
30
+ type="button"
31
+ onClick={onConfigure}
32
+ className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-warning hover:underline"
33
+ >
34
+ Configure SMTP
35
+ <ArrowRight className="size-3" aria-hidden="true" />
36
+ </button>
37
+ </div>
38
+ </div>
39
+ );
@@ -0,0 +1,22 @@
1
+ export interface ComposeSubjectFieldProps {
2
+ value: string;
3
+ onChange: (value: string) => void;
4
+ }
5
+
6
+ export const ComposeSubjectField = ({
7
+ value,
8
+ onChange,
9
+ }: ComposeSubjectFieldProps) => (
10
+ <div className="flex items-start gap-2">
11
+ {/* biome-ignore lint/a11y/noLabelWithoutControl: label is visually adjacent to the sibling input; static id risks duplicates */}
12
+ <label className="text-sm text-fg-muted shrink-0 w-12 pt-1.5">Subj:</label>
13
+ <input
14
+ type="text"
15
+ value={value}
16
+ onChange={(e) => onChange(e.target.value)}
17
+ className="flex-1 px-2 py-1.5 border rounded-md bg-canvas text-sm"
18
+ placeholder="Subject"
19
+ data-subject-field
20
+ />
21
+ </div>
22
+ );
@@ -0,0 +1,54 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { ConfirmDialog } from "./confirm-dialog.js";
3
+
4
+ const meta: Meta<typeof ConfirmDialog> = {
5
+ title: "Primitives/ConfirmDialog",
6
+ component: ConfirmDialog,
7
+ parameters: { layout: "centered" },
8
+ args: {
9
+ isOpen: true,
10
+ title: "Move 3,412 messages to Trash?",
11
+ description: "You can restore them from Trash later.",
12
+ confirmLabel: "Move to Trash",
13
+ destructive: true,
14
+ onConfirm: () => undefined,
15
+ onCancel: () => undefined,
16
+ },
17
+ };
18
+ export default meta;
19
+
20
+ type Story = StoryObj<typeof ConfirmDialog>;
21
+
22
+ /**
23
+ * A single corner tap on the bar's delete icon used to fall straight through
24
+ * to a delete with nothing in between — this is what now sits in the way.
25
+ * Wording says "Move … to Trash", not "Delete": the operation is reversible
26
+ * (IMAP delete moves to Trash), and the confirmation copy says so rather than
27
+ * reading as final.
28
+ */
29
+ export const Default: Story = {};
30
+
31
+ export const OneMessage: Story = {
32
+ args: {
33
+ title: "Move 1 message to Trash?",
34
+ },
35
+ };
36
+
37
+ /** The mutation is in flight: the confirm button disables rather than
38
+ * allowing a second concurrent delete request. */
39
+ export const Busy: Story = {
40
+ args: {
41
+ isBusy: true,
42
+ },
43
+ };
44
+
45
+ /** A non-destructive confirmation (no `destructive`) uses the accent
46
+ * affirmative styling instead of danger. */
47
+ export const NonDestructive: Story = {
48
+ args: {
49
+ title: "Archive 12 messages?",
50
+ description: undefined,
51
+ confirmLabel: "Archive",
52
+ destructive: false,
53
+ },
54
+ };
@@ -0,0 +1,140 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import { cn } from "../lib/cn.js";
3
+
4
+ export interface ConfirmDialogProps {
5
+ isOpen: boolean;
6
+ title: string;
7
+ /** Optional supporting line under the title. */
8
+ description?: string;
9
+ confirmLabel: string;
10
+ cancelLabel?: string;
11
+ /** Style the confirm button as a destructive action. */
12
+ destructive?: boolean;
13
+ /** Disable the confirm button (e.g. while a mutation is in flight). */
14
+ isBusy?: boolean;
15
+ onConfirm: () => void;
16
+ onCancel: () => void;
17
+ }
18
+
19
+ /**
20
+ * Minimal accessible confirmation dialog. No existing Dialog/ConfirmDialog
21
+ * primitive ships in the web client (only the bespoke KeyboardShortcutsModal
22
+ * and SlidePanel), so this is a small reusable one matching their Tailwind +
23
+ * overlay conventions. Esc cancels, the backdrop cancels, Cancel is focused on
24
+ * open, and the confirm/cancel pair is the only focusable content so the focus
25
+ * stays within the dialog.
26
+ */
27
+ export const ConfirmDialog = ({
28
+ isOpen,
29
+ title,
30
+ description,
31
+ confirmLabel,
32
+ cancelLabel = "Cancel",
33
+ destructive = false,
34
+ isBusy = false,
35
+ onConfirm,
36
+ onCancel,
37
+ }: ConfirmDialogProps) => {
38
+ const cancelRef = useRef<HTMLButtonElement>(null);
39
+
40
+ const handleKeyDown = useCallback(
41
+ (event: KeyboardEvent) => {
42
+ if (event.key === "Escape") {
43
+ event.preventDefault();
44
+ event.stopPropagation();
45
+ event.stopImmediatePropagation();
46
+ onCancel();
47
+ }
48
+ },
49
+ [onCancel],
50
+ );
51
+
52
+ useEffect(() => {
53
+ if (!isOpen) return;
54
+ // Capture phase so Esc closes the dialog before any list-level Esc
55
+ // handler (e.g. clearSelection) also fires on the same keystroke.
56
+ window.addEventListener("keydown", handleKeyDown, true);
57
+ return () => window.removeEventListener("keydown", handleKeyDown, true);
58
+ }, [isOpen, handleKeyDown]);
59
+
60
+ // Whoever opened the dialog gets the focus back when it closes. Without this
61
+ // a cancelled confirmation drops focus to the body, and the control the user
62
+ // was on — the compose mode toggle, a row's delete button — is gone from
63
+ // under the keyboard.
64
+ useEffect(() => {
65
+ if (!isOpen) return;
66
+ const opener =
67
+ document.activeElement instanceof HTMLElement
68
+ ? document.activeElement
69
+ : null;
70
+ cancelRef.current?.focus();
71
+ return () => {
72
+ if (opener?.isConnected) opener.focus();
73
+ };
74
+ }, [isOpen]);
75
+
76
+ if (!isOpen) return null;
77
+
78
+ return (
79
+ // Above every other overlay, the mobile compose sheet included: a
80
+ // confirmation is the decision blocking whatever is under it, and a drawer
81
+ // portalled to the body at the same level would cover it.
82
+ <div className="fixed inset-0 z-[60] flex items-center justify-center">
83
+ {/* Backdrop. It carries the click-to-dismiss and the aria-hidden: the
84
+ dialog itself must stay in the accessibility tree, and an
85
+ aria-hidden ancestor would take it out. */}
86
+ <div
87
+ className="absolute inset-0 bg-canvas/80 backdrop-blur-sm"
88
+ aria-hidden="true"
89
+ onClick={onCancel}
90
+ />
91
+
92
+ {/* Dialog */}
93
+ <div
94
+ role="dialog"
95
+ aria-modal="true"
96
+ aria-label={title}
97
+ className={cn(
98
+ "relative z-10 w-full max-w-sm",
99
+ "bg-surface border border-line rounded-sm shadow-lg",
100
+ "p-6",
101
+ )}
102
+ onClick={(e) => e.stopPropagation()}
103
+ onKeyDown={(e) => e.stopPropagation()}
104
+ >
105
+ <h2 className="text-lg font-semibold">{title}</h2>
106
+ {description && (
107
+ <p className="mt-2 text-sm text-fg-muted">{description}</p>
108
+ )}
109
+
110
+ <div className="mt-6 flex items-center justify-end gap-2">
111
+ <button
112
+ ref={cancelRef}
113
+ type="button"
114
+ onClick={onCancel}
115
+ className={cn(
116
+ "min-h-11 inline-flex items-center justify-center px-4 rounded text-sm font-medium transition-colors",
117
+ "border border-line hover:bg-surface-raised",
118
+ )}
119
+ >
120
+ {cancelLabel}
121
+ </button>
122
+ <button
123
+ type="button"
124
+ onClick={onConfirm}
125
+ disabled={isBusy}
126
+ className={cn(
127
+ "min-h-11 inline-flex items-center justify-center px-4 rounded text-sm font-medium transition-colors",
128
+ destructive
129
+ ? "bg-danger text-canvas hover:bg-danger/90"
130
+ : "bg-accent text-accent-fg hover:bg-accent-hover",
131
+ "disabled:opacity-50 disabled:cursor-not-allowed",
132
+ )}
133
+ >
134
+ {confirmLabel}
135
+ </button>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ );
140
+ };
package/src/index.ts CHANGED
@@ -130,16 +130,38 @@ export {
130
130
  type ComposeActionBarProps,
131
131
  type ComposeSaveStatus,
132
132
  } from "./components/compose-action-bar.js";
133
+ export {
134
+ type AddressEntry,
135
+ ComposeAddressField,
136
+ type ComposeAddressFieldProps,
137
+ } from "./components/compose-address-field.js";
133
138
  export {
134
139
  ComposeFormShell,
135
140
  type ComposeFormShellProps,
136
141
  type ComposeMode,
137
142
  composeModeLabels,
138
143
  } from "./components/compose-form-shell.js";
144
+ export {
145
+ ComposeHeader,
146
+ type ComposeHeaderProps,
147
+ composeHeaderSummary,
148
+ } from "./components/compose-header.js";
139
149
  export {
140
150
  ComposeLanguageSetting,
141
151
  type ComposeLanguageSettingProps,
142
152
  } from "./components/compose-language-setting.js";
153
+ export {
154
+ ComposeSmtpMissingBanner,
155
+ type ComposeSmtpMissingBannerProps,
156
+ } from "./components/compose-smtp-missing-banner.js";
157
+ export {
158
+ ComposeSubjectField,
159
+ type ComposeSubjectFieldProps,
160
+ } from "./components/compose-subject-field.js";
161
+ export {
162
+ ConfirmDialog,
163
+ type ConfirmDialogProps,
164
+ } from "./components/confirm-dialog.js";
143
165
  export {
144
166
  DangerZoneSection,
145
167
  type DangerZoneSectionProps,
@@ -674,6 +696,7 @@ export {
674
696
  unwrapLanguage,
675
697
  wrapWithLanguage,
676
698
  } from "./lib/compose-language.js";
699
+ export { modeOfDraft } from "./lib/compose-mode.js";
677
700
  export { generateLayoutClampCSS } from "./lib/email-layout-clamp.js";
678
701
  export {
679
702
  classifyEmailRenderTreatment,