@remit/ui 0.0.121 → 0.0.123

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/src/components/app-shell-slotted.tsx +5 -1
  3. package/src/components/compose-body.language.test.ts +236 -0
  4. package/src/components/compose-body.stories.tsx +26 -0
  5. package/src/components/compose-form-shell.render.test.ts +59 -0
  6. package/src/components/compose-form-shell.tsx +36 -10
  7. package/src/components/email-frame-css.ts +110 -29
  8. package/src/components/isolated-email-frame.render.test.ts +174 -36
  9. package/src/components/isolated-email-frame.stories.tsx +518 -33
  10. package/src/components/isolated-email-frame.tsx +58 -135
  11. package/src/components/message-body-view.stories.tsx +200 -2
  12. package/src/components/message-body-view.tsx +82 -33
  13. package/src/components/mobile-reading-pane.tsx +2 -1
  14. package/src/components/reading-pane.stories.tsx +138 -3
  15. package/src/components/reading-pane.tsx +35 -26
  16. package/src/components/resizable.tsx +42 -0
  17. package/src/components/selection-wizard.render.test.ts +42 -1
  18. package/src/components/selection-wizard.tsx +50 -29
  19. package/src/components/slide-panel.tsx +7 -3
  20. package/src/index.ts +8 -1
  21. package/src/lib/compose-language.test.ts +17 -0
  22. package/src/lib/compose-language.ts +17 -6
  23. package/src/lib/detect-compose-language.test.ts +18 -0
  24. package/src/lib/email-layout-clamp.test.ts +30 -0
  25. package/src/lib/email-layout-clamp.ts +17 -0
  26. package/src/lib/email-sanitizer.test.ts +153 -0
  27. package/src/lib/email-sanitizer.ts +165 -0
  28. package/src/lib/keymap.test.ts +16 -0
  29. package/src/lib/keymap.ts +16 -4
  30. package/src/lib/wizard-steps.test.ts +11 -0
  31. package/src/lib/wizard-steps.ts +21 -0
@@ -55,6 +55,35 @@ const EmptyBody = () => (
55
55
  </p>
56
56
  );
57
57
 
58
+ /**
59
+ * The message-body region of a reading pane. The sandboxed email frame — and
60
+ * only it — leaves the message's gutter, so mail renders on its own ground with
61
+ * its own margins and no app canvas shows down either side of it (#763).
62
+ * Everything else in the region is app chrome (the blocked-images notice, a
63
+ * plain-text body, an error, the attachment list) and keeps the inset the
64
+ * header has.
65
+ *
66
+ * The rule lives here, next to the `message-body-frame` marker it moves, so the
67
+ * pane and Storybook cannot drift apart on it. The negative margins mirror the
68
+ * message block's own `px-2 lg:px-4`.
69
+ */
70
+ export const MessageBodyRegion = ({
71
+ className,
72
+ children,
73
+ }: {
74
+ className?: string;
75
+ children: ReactNode;
76
+ }) => (
77
+ <div
78
+ className={cn(
79
+ "[&_.message-body-frame]:-mx-2 lg:[&_.message-body-frame]:-mx-4",
80
+ className,
81
+ )}
82
+ >
83
+ {children}
84
+ </div>
85
+ );
86
+
58
87
  /**
59
88
  * Render an email body the way the app does: sanitize the raw HTML
60
89
  * (DOMPurify + privacy/XSS scrubbing), classify it as framed (designed mail —
@@ -92,6 +121,16 @@ export const MessageBodyView = ({
92
121
  sanitized?.hasAuthorBackground ?? false,
93
122
  );
94
123
 
124
+ // Stable across renders: the frame rebuilds its srcDoc — and so reloads the
125
+ // iframe — whenever this changes identity.
126
+ const declares = useMemo(
127
+ () => ({
128
+ background: sanitized?.hasAuthorBackground ?? false,
129
+ spacing: sanitized?.hasAuthorSpacing ?? false,
130
+ }),
131
+ [sanitized?.hasAuthorBackground, sanitized?.hasAuthorSpacing],
132
+ );
133
+
95
134
  const blockedImageCount = useMemo(() => {
96
135
  if (!sanitizedHtml || allowImages) return 0;
97
136
  return (sanitizedHtml.match(/data-blocked-src/g) || []).length;
@@ -107,46 +146,56 @@ export const MessageBodyView = ({
107
146
 
108
147
  return (
109
148
  <div className={cn("message-body", className)}>
110
- {/* `message-body-notice` is a styling hook, not a style: a container
111
- that drops the body's horizontal inset (the mobile reading pane)
112
- uses it to give the gutter back to this notice, which is app
113
- chrome rather than part of the email. */}
149
+ {/* The notice keeps the container's gutter: it is app chrome, not part
150
+ of the email, and only `message-body-frame` below leaves the
151
+ gutter. */}
114
152
  {blockedImageCount > 0 && (
115
- <div className="message-body-notice">
116
- {renderBlockedNotice?.(blockedImageCount)}
117
- </div>
153
+ <div>{renderBlockedNotice?.(blockedImageCount)}</div>
118
154
  )}
119
155
 
120
156
  {sanitizedHtml ? (
121
157
  // Email HTML renders inside a sandboxed iframe so its own CSS and any
122
158
  // (already-DOMPurify'd) markup cannot bleed into the app chrome. The
123
- // frame sizes itself to its content; its sandbox omits `allow-scripts`
124
- // so even a hypothetical sanitizer escape can't execute.
125
- framed ? (
126
- // Full-width wrapper so a fluid newsletter fills the reading column;
127
- // max-w-full + overflow-x-auto trap any residual wide content inside
128
- // this box rather than dragging the page on mobile. No border,
129
- // padding or background the email renders flush (#727).
130
- <div className="w-full max-w-full overflow-x-auto">
131
- <IsolatedEmailFrame
132
- html={sanitizedHtml}
133
- variant="framed"
134
- isDark={isDark}
135
- />
136
- </div>
137
- ) : (
138
- // `lg:max-w-2xl` caps the reading column on desktop; `max-w-full`
139
- // keeps the box within the viewport on mobile. `overflow-x-auto`
140
- // traps residual wide content INSIDE this box on a phone (#727).
141
- <div className="max-w-full overflow-x-auto lg:max-w-2xl">
142
- <IsolatedEmailFrame
143
- html={sanitizedHtml}
144
- variant={isPlain ? "plain" : "framed"}
145
- isDark={isDark}
146
- />
147
- </div>
148
- )
159
+ // frame is the width of this box and never the width of the mail —
160
+ // content that cannot wrap scrolls inside the frame's own document —
161
+ // and its sandbox omits `allow-scripts` so even a hypothetical
162
+ // sanitizer escape can't execute.
163
+ //
164
+ // `message-body-frame` marks the box the gutter cancel moves, and it
165
+ // carries no width of its own: a block with both a width and a margin
166
+ // is over-constrained, and the browser resolves that by dropping the
167
+ // right margin — which left a strip of app canvas down one side of
168
+ // every email. The width lives on the child instead.
169
+ <div className="message-body-frame">
170
+ {framed ? (
171
+ // Full-width wrapper so a fluid newsletter fills the reading
172
+ // column. No border, padding or background — the email renders
173
+ // flush (#727).
174
+ <div className="w-full max-w-full">
175
+ <IsolatedEmailFrame
176
+ html={sanitizedHtml}
177
+ variant="framed"
178
+ isDark={isDark}
179
+ declares={declares}
180
+ />
181
+ </div>
182
+ ) : (
183
+ // `lg:max-w-2xl` caps the reading column on desktop; `max-w-full`
184
+ // keeps the box within the viewport on mobile.
185
+ <div className="max-w-full lg:max-w-2xl">
186
+ <IsolatedEmailFrame
187
+ html={sanitizedHtml}
188
+ variant={isPlain ? "plain" : "framed"}
189
+ isDark={isDark}
190
+ declares={declares}
191
+ />
192
+ </div>
193
+ )}
194
+ </div>
149
195
  ) : text ? (
196
+ // Plain text is not an email document: it has no ground of its own and
197
+ // no layout to respect, so it keeps the message's gutter rather than
198
+ // running to the pane edge like the sandboxed frame does.
150
199
  <pre className="email-text whitespace-pre-wrap text-sm leading-relaxed">
151
200
  {text}
152
201
  </pre>
@@ -134,8 +134,9 @@ export function MobileReadingPane({
134
134
  style={touchHandlers ? { touchAction: "pan-y" } : undefined}
135
135
  {...touchHandlers}
136
136
  >
137
+ {/* Newest first, as on the wide pane. */}
137
138
  {children ??
138
- thread.messages.map((message) => {
139
+ [...thread.messages].reverse().map((message) => {
139
140
  const bind = (handler?: (id: string) => void) =>
140
141
  handler ? () => handler(message.id) : undefined;
141
142
 
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { Paperclip, Star } from "lucide-react";
3
3
  import { useState } from "react";
4
+ import { expect } from "storybook/test";
4
5
  import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
5
6
  import {
6
7
  type AttachmentDownloadState,
@@ -42,7 +43,7 @@ const thread: ThreadData = {
42
43
  };
43
44
 
44
45
  // A designed (framed) newsletter so the Screens story exercises the REAL
45
- // renderer — sanitized + sandboxed iframe, flush layout, #727 scale-to-fit
46
+ // renderer — sanitized + sandboxed iframe, flush layout, in-document overflow
46
47
  // rather than a plain inline paragraph (#940). The fixed 600px table is the
47
48
  // kind of markup that overflowed a phone before #727.
48
49
  const newsletterThread: ThreadData = {
@@ -72,6 +73,40 @@ const newsletterThread: ThreadData = {
72
73
  ],
73
74
  };
74
75
 
76
+ // Formatted HTML mail that declares no background, no padding and no width —
77
+ // the ordinary case. The app supplies the ground and the breathing room, so the
78
+ // email must read as one surface with the pane rather than a card inside it: no
79
+ // border, no accent line down the side, no gutter of app canvas beside it.
80
+ const bareThread: ThreadData = {
81
+ subject: "Repetitie donderdag",
82
+ messages: [
83
+ {
84
+ id: "bare-1",
85
+ fromName: "Ingrid Bakker",
86
+ fromEmail: "ingrid@koor.example",
87
+ toLabel: "you",
88
+ dateLabel: "Today, 11:24",
89
+ snippet: "De repetitie van donderdag gaat door…",
90
+ expanded: true,
91
+ bodyHtml: `<div>
92
+ <p>Hoi allemaal,</p>
93
+ <p>De repetitie van donderdag gaat door. We beginnen met het nieuwe stuk en
94
+ repeteren om 20.00 uur verder aan het programma voor het najaarsconcert.</p>
95
+ <p><b>Neem je eigen partituur mee</b> — er zijn geen reservekopie&euml;n.</p>
96
+ <p>Groeten,<br>Ingrid</p>
97
+ </div>`,
98
+ },
99
+ ],
100
+ };
101
+
102
+ const PLAIN_TEXT_BODY = `Hoi allemaal,
103
+
104
+ De repetitie van donderdag gaat door. We beginnen met het nieuwe stuk en
105
+ repeteren om 20.00 uur verder aan het programma voor het najaarsconcert.
106
+
107
+ Groeten,
108
+ Ingrid`;
109
+
75
110
  const meta: Meta<typeof ReadingPane> = {
76
111
  title: "Screens/Kit/ReadingPane",
77
112
  component: ReadingPane,
@@ -89,9 +124,29 @@ type Story = StoryObj<typeof ReadingPane>;
89
124
  export const WithThread: Story = { args: { thread } };
90
125
 
91
126
  /** A designed newsletter rendered through the real sanitize → sandboxed-iframe
92
- * pipeline — the same rendering the live app shows (#940). */
127
+ * pipeline — the same rendering the live app shows (#940). It brings its own
128
+ * background and its own 24px padding and keeps both, undoubled. */
93
129
  export const Newsletter: Story = { args: { thread: newsletterThread } };
94
130
 
131
+ /** The same newsletter on the dark pane: darkened as authored, not repainted. */
132
+ export const NewsletterDark: Story = {
133
+ args: { thread: newsletterThread },
134
+ parameters: { theme: "dark" },
135
+ };
136
+
137
+ /** Ordinary formatted mail that declares nothing of its own. The sender header
138
+ * keeps its inset; below it the email is one surface with the pane — no card
139
+ * edge, no accent line, no app gutter beside it — with the breathing room
140
+ * inside the email's own ground. */
141
+ export const BareHtmlMail: Story = { args: { thread: bareThread } };
142
+
143
+ /** The same on the dark pane, where a lighter app-supplied ground used to show
144
+ * as a rectangle seamed into the pane. */
145
+ export const BareHtmlMailDark: Story = {
146
+ args: { thread: bareThread },
147
+ parameters: { theme: "dark" },
148
+ };
149
+
95
150
  export const Empty: Story = { args: { thread: undefined } };
96
151
 
97
152
  export const WithIntelligenceToggle: Story = {
@@ -252,7 +307,7 @@ const ThreadAttachments = () => {
252
307
 
253
308
  return (
254
309
  <AttachmentList
255
- className="mt-4 px-2 lg:px-0"
310
+ className="mt-4"
256
311
  attachments={rows}
257
312
  onDownload={(attachmentId) => {
258
313
  setStatus(attachmentId, { status: "downloading" });
@@ -293,3 +348,83 @@ export const ExpandedRowWithAttachments: StoryObj<typeof ExpandedMessage> = {
293
348
  </div>
294
349
  ),
295
350
  };
351
+
352
+ /**
353
+ * The email sits square in the pane: the frame leaves the message gutter on
354
+ * both sides, not just the left.
355
+ *
356
+ * A block box cannot have both a width and two margins — the browser resolves
357
+ * the over-constraint by throwing the right margin away — so a gutter cancel on
358
+ * a `w-full` box moved the mail left and left a strip of app canvas down its
359
+ * right-hand side. Bare mail hides that (it paints the pane's own colour); a
360
+ * newsletter with a ground of its own shows it plainly, which is why this
361
+ * measures a framed message.
362
+ */
363
+ const framedRow: ThreadMessageData = {
364
+ ...newsletterThread.messages[0],
365
+ id: "symmetry-1",
366
+ };
367
+
368
+ const paneGutters = (canvasElement: HTMLElement) => {
369
+ const pane = canvasElement.querySelector<HTMLElement>("[data-pane]");
370
+ const frame = pane?.querySelector<HTMLElement>(".message-body-frame");
371
+ if (!pane || !frame) throw new Error("no framed message body in the pane");
372
+ const paneBox = pane.getBoundingClientRect();
373
+ const frameBox = frame.getBoundingClientRect();
374
+ return {
375
+ left: frameBox.left - paneBox.left,
376
+ right: paneBox.right - frameBox.right,
377
+ };
378
+ };
379
+
380
+ const assertSymmetric = async (canvasElement: HTMLElement) => {
381
+ const { left, right } = paneGutters(canvasElement);
382
+ await expect(Math.abs(left - right)).toBeLessThan(1);
383
+ await expect(Math.abs(left)).toBeLessThan(1);
384
+ };
385
+
386
+ /** A designed newsletter on a desktop reading column. */
387
+ export const FramedBodySitsSquareInThePane: StoryObj<typeof ExpandedMessage> = {
388
+ render: () => (
389
+ <div data-pane className="w-[900px] bg-canvas">
390
+ <ExpandedMessage message={framedRow} />
391
+ </div>
392
+ ),
393
+ play: async ({ canvasElement }) => {
394
+ await assertSymmetric(canvasElement);
395
+ },
396
+ };
397
+
398
+ /** The same at a phone width, where the gutter is half as wide and a lost
399
+ * right margin is half the pane's breathing room. */
400
+ export const FramedBodySitsSquareOnAPhone: StoryObj<typeof ExpandedMessage> = {
401
+ render: () => (
402
+ <div data-pane className="w-[390px] bg-canvas">
403
+ <ExpandedMessage message={framedRow} />
404
+ </div>
405
+ ),
406
+ play: async ({ canvasElement }) => {
407
+ await assertSymmetric(canvasElement);
408
+ },
409
+ };
410
+
411
+ /**
412
+ * A message with no HTML part. The two body paths side by side: an email
413
+ * document runs flush on its own ground, while plain text — which has neither —
414
+ * keeps the message gutter and stays off the pane edge.
415
+ */
416
+ export const PlainTextMessage: StoryObj<typeof ExpandedMessage> = {
417
+ render: () => (
418
+ <div className="max-w-3xl bg-canvas">
419
+ <ExpandedMessage
420
+ message={{ ...row, fromName: "Ingrid Bakker" }}
421
+ to={<>to the choir list</>}
422
+ body={
423
+ <div className="mt-3">
424
+ <MessageBodyView text={PLAIN_TEXT_BODY} />
425
+ </div>
426
+ }
427
+ />
428
+ </div>
429
+ ),
430
+ };
@@ -6,7 +6,7 @@ import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
6
6
  import { Avatar } from "./avatar.js";
7
7
  import { IntelligenceToggle } from "./intelligence-toggle.js";
8
8
  import { MailActionToolbar } from "./mail-action-toolbar.js";
9
- import { MessageBodyView } from "./message-body-view.js";
9
+ import { MessageBodyRegion, MessageBodyView } from "./message-body-view.js";
10
10
  import { ReadingPaneEmpty } from "./reading-pane-empty.js";
11
11
 
12
12
  /* ------------------------------------------------------------------ */
@@ -133,6 +133,11 @@ export function ExpandedMessage({
133
133
  warning?: string;
134
134
  /** Collapse handler on the sender block. Omit for a static row. */
135
135
  onHeaderClick?: () => void;
136
+ /**
137
+ * Keyboard cursor. Rings the sender row, the way it rings a collapsed row —
138
+ * never the message, which would draw an accent line down both sides of the
139
+ * email and read as a card edge around mail that carries its own.
140
+ */
136
141
  isFocused?: boolean;
137
142
  /** Rendered after the sender name (e.g. a trusted-sender badge). */
138
143
  senderBadge?: ReactNode;
@@ -177,13 +182,17 @@ export function ExpandedMessage({
177
182
  );
178
183
 
179
184
  return (
180
- <div
181
- className={cn(
182
- "px-2 py-3 lg:px-4",
183
- isFocused && "ring-1 ring-inset ring-accent/30",
184
- )}
185
- >
186
- <div className="flex items-start gap-3">
185
+ <div className="px-2 py-3 lg:px-4">
186
+ {/* The cursor rings the sender row, not the message. Around the message
187
+ it drew an accent line down both sides of every open email, which
188
+ reads as a card edge on mail that brings its own. The negative
189
+ margins hold the row exactly where it sits unringed. */}
190
+ <div
191
+ className={cn(
192
+ "-mx-2 -my-1 flex items-start gap-3 rounded-sm px-2 py-1",
193
+ isFocused && "ring-1 ring-inset ring-accent/30",
194
+ )}
195
+ >
187
196
  {/* The chevron is the disclosure control, not a picture of one: it is
188
197
  what a reader aims at to put a message away, so it carries the
189
198
  collapse itself rather than sitting beside the sender block that
@@ -240,12 +249,7 @@ export function ExpandedMessage({
240
249
  exactly what the app renders, not a divergent inline-HTML mock
241
250
  (#940). `framed` fixtures map to the newsletter treatment (author
242
251
  colors preserved); the rest render plain. */}
243
- {/* On a phone the email runs edge to edge: the negative margin cancels
244
- this block's `px-2` so no app canvas shows beside the body and the
245
- email doesn't read as sitting in a tinted frame (#763). The header
246
- keeps its inset. The blocked-images notice is app chrome, not part
247
- of the email, so it takes the gutter back. Desktop is unchanged. */}
248
- <div className="-mx-2 [&_.message-body-notice]:px-2 lg:mx-0 lg:[&_.message-body-notice]:px-0">
252
+ <MessageBodyRegion>
249
253
  {body ?? (
250
254
  <MessageBodyView
251
255
  className="mt-3"
@@ -254,7 +258,7 @@ export function ExpandedMessage({
254
258
  allowImages
255
259
  />
256
260
  )}
257
- </div>
261
+ </MessageBodyRegion>
258
262
  </div>
259
263
  );
260
264
  }
@@ -332,17 +336,22 @@ export function ReadingPane({
332
336
  </p>
333
337
  </div>
334
338
 
335
- {thread.messages.map((message) =>
336
- message.expanded ? (
337
- <ExpandedMessage
338
- key={message.id}
339
- message={message}
340
- warning={thread.warning}
341
- />
342
- ) : (
343
- <CollapsedMessage key={message.id} message={message} />
344
- ),
345
- )}
339
+ {/* Newest first. A thread arrives in the order it happened; the
340
+ turn a reader opened it for is the last one, and reading it
341
+ should not cost a scroll past everything that led there. */}
342
+ {[...thread.messages]
343
+ .reverse()
344
+ .map((message) =>
345
+ message.expanded ? (
346
+ <ExpandedMessage
347
+ key={message.id}
348
+ message={message}
349
+ warning={thread.warning}
350
+ />
351
+ ) : (
352
+ <CollapsedMessage key={message.id} message={message} />
353
+ ),
354
+ )}
346
355
  </div>
347
356
  )}
348
357
  </article>
@@ -1,3 +1,4 @@
1
+ import { type ReactNode, useEffect, useRef, useState } from "react";
1
2
  import {
2
3
  Panel,
3
4
  PanelGroup,
@@ -52,4 +53,45 @@ export function ResizableHandle({
52
53
  );
53
54
  }
54
55
 
56
+ /**
57
+ * Gives everything inside it a whole-pixel box. `react-resizable-panels` sizes a
58
+ * pane with a fractional `flexGrow`, so a pane lands on 712.5px and every box
59
+ * under it inherits the fraction — and a fraction is where the DOM's whole-pixel
60
+ * measurements start disagreeing with each other. Floor rather than round, so
61
+ * the box is never a hair wider than the pane holding it.
62
+ */
63
+ export function WholePixelWidth({
64
+ className,
65
+ children,
66
+ }: {
67
+ className?: string;
68
+ children: ReactNode;
69
+ }) {
70
+ const ref = useRef<HTMLDivElement>(null);
71
+ const [width, setWidth] = useState<number | null>(null);
72
+
73
+ useEffect(() => {
74
+ const pane = ref.current?.parentElement;
75
+ if (!pane || typeof ResizeObserver === "undefined") return;
76
+ const measure = () => {
77
+ const next = Math.floor(pane.getBoundingClientRect().width);
78
+ setWidth((prev) => (prev === next ? prev : next));
79
+ };
80
+ measure();
81
+ const observer = new ResizeObserver(measure);
82
+ observer.observe(pane);
83
+ return () => observer.disconnect();
84
+ }, []);
85
+
86
+ return (
87
+ <div
88
+ ref={ref}
89
+ className={className}
90
+ style={width === null ? undefined : { width: `${width}px` }}
91
+ >
92
+ {children}
93
+ </div>
94
+ );
95
+ }
96
+
55
97
  export type { PanelGroupProps, PanelProps, PanelResizeHandleProps };
@@ -3,7 +3,11 @@ import { describe, it } from "node:test";
3
3
  import { createElement } from "react";
4
4
  import { renderToString } from "react-dom/server";
5
5
  import type { MatchCount, StepId, WizardDraft } from "../lib/wizard-steps.js";
6
- import { stepsFor } from "../lib/wizard-steps.js";
6
+ import {
7
+ matchDoorsFor,
8
+ stepBlockedReason,
9
+ stepsFor,
10
+ } from "../lib/wizard-steps.js";
7
11
  import {
8
12
  type RuleClause,
9
13
  UNCOUNTABLE_PREDICATE_REASON,
@@ -57,6 +61,7 @@ const sample = { messages, count: counted(2), label: "Your selection" };
57
61
  const matchProps = {
58
62
  selectedCount: 2,
59
63
  mode: "selected" as const,
64
+ accountId: "acc-personal",
60
65
  onModeChange: noop,
61
66
  onSemanticFallback: noop,
62
67
  sample,
@@ -291,6 +296,42 @@ describe("MatchStepBody", () => {
291
296
  });
292
297
  });
293
298
 
299
+ /**
300
+ * Starred, and any other surface whose rows span accounts (#523). Both widened
301
+ * doors are counted through a preview one account answers, so offering them
302
+ * walks the user to a review waiting on a count nobody can take, with Back the
303
+ * only way on. They are withheld, and the step says why.
304
+ */
305
+ describe("MatchStepBody with no single account", () => {
306
+ const spanning = { ...matchProps, accountId: undefined };
307
+
308
+ it("offers the ticked rows alone, and states the restriction", () => {
309
+ const html = text(renderToString(createElement(MatchStepBody, spanning)));
310
+ assert.match(html, /These 2 messages/);
311
+ assert.doesNotMatch(html, /Similar to these/);
312
+ assert.doesNotMatch(html, /Its properties/);
313
+ assert.match(html, /only works within one account/);
314
+ });
315
+
316
+ it("leaves a review that commits rather than one pinned on the count", () => {
317
+ assert.deepEqual([...matchDoorsFor(undefined)], ["selected"]);
318
+ // The only door left is its own count, so nothing about the match is still
319
+ // settling by the time the review is reached.
320
+ const blockedReason = stepBlockedReason("review", draft(), counted(2));
321
+ assert.equal(blockedReason, undefined);
322
+ const html = text(
323
+ renderToString(
324
+ createElement(
325
+ SelectionWizard,
326
+ wizardProps({ step: "review", match: spanning, blockedReason }),
327
+ ),
328
+ ),
329
+ );
330
+ assert.doesNotMatch(html, /Counting matches/);
331
+ assert.match(html, /2 messages/);
332
+ });
333
+ });
334
+
294
335
  describe("PropertiesStepBody", () => {
295
336
  it("renders the rule as the shipped chips, with the join between them", () => {
296
337
  const html = renderToString(
@@ -21,6 +21,7 @@ import { Fragment, type ReactNode, useEffect, useId, useRef } from "react";
21
21
  import { cn } from "../lib/cn.js";
22
22
  import {
23
23
  backExits,
24
+ crossAccountMatchReason,
24
25
  ESCALATED_MATCH_HINT,
25
26
  ESCALATED_REVIEW_WARNING,
26
27
  ESCALATED_SCOPE_FALLBACK,
@@ -30,6 +31,7 @@ import {
30
31
  type MatchMode,
31
32
  matchDoorHint,
32
33
  matchDoorLabel,
34
+ matchDoorsFor,
33
35
  matchPhrase,
34
36
  matchSummary,
35
37
  type RunCopy,
@@ -364,6 +366,13 @@ export function FooterNav({
364
366
  export interface MatchStepProps {
365
367
  selectedCount: number;
366
368
  mode: MatchMode;
369
+ /**
370
+ * The one account this selection belongs to, absent when it spans several
371
+ * (#523). Both widened doors are counted through a preview that account
372
+ * answers, so without one they are withheld and the step states the
373
+ * restriction — the ticked rows are their own match and stay on offer.
374
+ */
375
+ accountId?: string;
367
376
  /**
368
377
  * Answers the door. Typed to the three doors rather than to every mode, so
369
378
  * no driver can set `escalated` from a screen — the list escalates a
@@ -396,6 +405,7 @@ export interface MatchStepProps {
396
405
  export function MatchStepBody({
397
406
  selectedCount,
398
407
  mode,
408
+ accountId,
399
409
  onModeChange,
400
410
  semanticUnavailable,
401
411
  semanticErrorDetail,
@@ -419,44 +429,55 @@ export function MatchStepBody({
419
429
  </>
420
430
  );
421
431
  }
432
+ const doors = matchDoorsFor(accountId);
433
+ const widened = doors.length > 1;
422
434
  return (
423
435
  <>
424
436
  <div className="space-y-2">
437
+ {!widened && (
438
+ <p className="px-1 text-2xs text-warning">
439
+ {crossAccountMatchReason}
440
+ </p>
441
+ )}
425
442
  <ChoiceCard
426
443
  selected={mode === "selected"}
427
444
  onSelect={() => onModeChange("selected")}
428
445
  title={matchDoorLabel("selected", selectedCount)}
429
446
  description={matchDoorHint("selected")}
430
447
  />
431
- <ChoiceCard
432
- selected={mode === "similar"}
433
- unavailable={semanticUnavailable}
434
- onSelect={
435
- semanticUnavailable
436
- ? onSemanticFallback
437
- : () => onModeChange("similar")
438
- }
439
- title={matchDoorLabel("similar", selectedCount)}
440
- description={matchDoorHint("similar")}
441
- >
442
- {semanticErrorDetail && (
443
- <p role="status" className="px-1 text-2xs text-danger">
444
- Couldn't find similar messages: {semanticErrorDetail}
445
- </p>
446
- )}
447
- {semanticFallbackTaken && (
448
- <p role="status" className="px-1 text-2xs text-fg-subtle">
449
- Similar-mail matching is unavailable right now — matching on the
450
- senders instead.
451
- </p>
452
- )}
453
- </ChoiceCard>
454
- <ChoiceCard
455
- selected={mode === "properties"}
456
- onSelect={() => onModeChange("properties")}
457
- title={matchDoorLabel("properties", selectedCount)}
458
- description={matchDoorHint("properties")}
459
- />
448
+ {widened && (
449
+ <ChoiceCard
450
+ selected={mode === "similar"}
451
+ unavailable={semanticUnavailable}
452
+ onSelect={
453
+ semanticUnavailable
454
+ ? onSemanticFallback
455
+ : () => onModeChange("similar")
456
+ }
457
+ title={matchDoorLabel("similar", selectedCount)}
458
+ description={matchDoorHint("similar")}
459
+ >
460
+ {semanticErrorDetail && (
461
+ <p role="status" className="px-1 text-2xs text-danger">
462
+ Couldn't find similar messages: {semanticErrorDetail}
463
+ </p>
464
+ )}
465
+ {semanticFallbackTaken && (
466
+ <p role="status" className="px-1 text-2xs text-fg-subtle">
467
+ Similar-mail matching is unavailable right now — matching on the
468
+ senders instead.
469
+ </p>
470
+ )}
471
+ </ChoiceCard>
472
+ )}
473
+ {widened && (
474
+ <ChoiceCard
475
+ selected={mode === "properties"}
476
+ onSelect={() => onModeChange("properties")}
477
+ title={matchDoorLabel("properties", selectedCount)}
478
+ description={matchDoorHint("properties")}
479
+ />
480
+ )}
460
481
  </div>
461
482
  <div className="mt-4">
462
483
  <SelectionSample {...sample} />