@remit/ui 0.0.9 → 0.0.11

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/ui",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -269,9 +269,7 @@ function AppShellReading({
269
269
  thread={thread}
270
270
  intelligenceOpen={showIntelligence}
271
271
  onToggleIntelligence={onToggleIntelligence}
272
- showIntelligenceToggle={
273
- isWide && Boolean(intelligence) && Boolean(thread)
274
- }
272
+ canToggleIntelligence={isWide && Boolean(intelligence) && Boolean(thread)}
275
273
  />
276
274
  );
277
275
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Render invariants for the (i) intelligence toggle (#52): the control holds
3
+ * its slot in the toolbar on every view and in every selection state, and
4
+ * reports "cannot act" by being disabled rather than by disappearing.
5
+ */
6
+
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "node:test";
9
+ import { createElement } from "react";
10
+ import { renderToString } from "react-dom/server";
11
+ import { IntelligenceToggle } from "./intelligence-toggle.js";
12
+
13
+ const render = (props: Parameters<typeof IntelligenceToggle>[0]): string =>
14
+ renderToString(createElement(IntelligenceToggle, props));
15
+
16
+ describe("IntelligenceToggle", () => {
17
+ it("renders the button when it cannot act", () => {
18
+ const html = render({ enabled: false });
19
+ assert.match(html, /aria-label="Show intelligence sidebar"/);
20
+ });
21
+
22
+ it("disables rather than hides the button when it cannot act", () => {
23
+ const html = render({ enabled: false });
24
+ assert.match(html, /\sdisabled(=""|\s|>)/);
25
+ });
26
+
27
+ it("is pressable when a thread is open and a rail exists", () => {
28
+ const html = render({ enabled: true });
29
+ assert.equal(/\sdisabled(=""|\s|>)/.test(html), false);
30
+ });
31
+
32
+ it("stays rendered while the rail is open so it can close it again", () => {
33
+ const html = render({ enabled: true, open: true });
34
+ assert.match(html, /aria-label="Hide intelligence sidebar"/);
35
+ assert.match(html, /aria-pressed="true"/);
36
+ });
37
+ });
@@ -0,0 +1,53 @@
1
+ import { Info } from "lucide-react";
2
+ import { cn } from "../lib/cn.js";
3
+ import { Button } from "./button.js";
4
+
5
+ export interface IntelligenceToggleProps {
6
+ /** Whether the intelligence rail is currently open. */
7
+ open?: boolean;
8
+ /**
9
+ * Whether the toggle can act right now — a rail exists for this view, the
10
+ * width allows it, and a thread is open. When false the button still renders,
11
+ * greyed out (#52).
12
+ */
13
+ enabled?: boolean;
14
+ onToggle?: () => void;
15
+ className?: string;
16
+ }
17
+
18
+ /**
19
+ * The (i) intelligence-rail toggle in the reading-pane toolbar.
20
+ *
21
+ * The toolbar's control set is fixed: the toggle occupies the same slot on
22
+ * every view and in every selection state, and reports that it cannot act by
23
+ * being disabled rather than by vanishing (#52). A control that appears and
24
+ * disappears moves its neighbours and leaves the user with nothing to learn
25
+ * from; a greyed-out one stays where they last saw it.
26
+ *
27
+ * The title names the control rather than the reason it is off. There are three
28
+ * reasons (nothing selected, a window too narrow for the rail, a view with no
29
+ * rail), a disabled `Button` is `pointer-events-none` so no tooltip surfaces
30
+ * anyway, and a reason string that covers one case is wrong in the other two.
31
+ */
32
+ export function IntelligenceToggle({
33
+ open = false,
34
+ enabled = true,
35
+ onToggle,
36
+ className,
37
+ }: IntelligenceToggleProps) {
38
+ return (
39
+ <Button
40
+ variant="ghost"
41
+ size="sm"
42
+ icon={<Info className="size-4" />}
43
+ title="Intelligence"
44
+ aria-label={
45
+ open ? "Hide intelligence sidebar" : "Show intelligence sidebar"
46
+ }
47
+ aria-pressed={open}
48
+ disabled={!enabled}
49
+ onClick={onToggle}
50
+ className={cn(open && "bg-accent-2-soft text-accent-2", className)}
51
+ />
52
+ );
53
+ }
@@ -88,7 +88,7 @@ export const Newsletter: Story = { args: { thread: newsletterThread } };
88
88
  export const Empty: Story = { args: { thread: undefined } };
89
89
 
90
90
  export const WithIntelligenceToggle: Story = {
91
- args: { thread, showIntelligenceToggle: true, intelligenceOpen: false },
91
+ args: { thread, canToggleIntelligence: true, intelligenceOpen: false },
92
92
  };
93
93
 
94
94
  /* ------------------------------------------------------------------ */
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  ChevronDown,
3
3
  ChevronRight,
4
- Info,
5
4
  Search,
6
5
  ShieldAlert,
7
6
  SquarePen,
@@ -13,6 +12,7 @@ import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
13
12
  import { Avatar } from "./avatar.js";
14
13
  import { Button } from "./button.js";
15
14
  import { Input } from "./input.js";
15
+ import { IntelligenceToggle } from "./intelligence-toggle.js";
16
16
  import { MailActionToolbar } from "./mail-action-toolbar.js";
17
17
  import { MessageBodyView } from "./message-body-view.js";
18
18
  import { ReadingPaneEmpty } from "./reading-pane-empty.js";
@@ -248,19 +248,21 @@ export function ExpandedMessage({
248
248
  * Message action toolbar on the pane-header datum: the reading pane's
249
249
  * verbs (reply/reply-all/forward, delete/move/flag) plus search and
250
250
  * compose, Apple Mail-style above the message area. Built on the shared
251
- * `MailActionToolbar` so the buttons stay pressable with no thread open and
252
- * explain inline rather than greying out (the never-disable tenet).
251
+ * `MailActionToolbar` so the mail verbs stay pressable with no
252
+ * thread open and explain inline rather than greying out (the never-disable
253
+ * tenet). The intelligence toggle is the exception: it holds its slot and greys
254
+ * out when there is no rail to open (#52).
253
255
  */
254
256
  function MessageToolbar({
255
257
  hasThread,
256
258
  intelligenceOpen,
257
259
  onToggleIntelligence,
258
- showIntelligenceToggle,
260
+ canToggleIntelligence = false,
259
261
  }: {
260
262
  hasThread: boolean;
261
263
  intelligenceOpen?: boolean;
262
264
  onToggleIntelligence?: () => void;
263
- showIntelligenceToggle?: boolean;
265
+ canToggleIntelligence?: boolean;
264
266
  }) {
265
267
  const [hint, setHint] = useState<string | null>(null);
266
268
  return (
@@ -284,15 +286,11 @@ function MessageToolbar({
284
286
  title="Compose (⌘N)"
285
287
  aria-label="Compose"
286
288
  />
287
- {showIntelligenceToggle && !intelligenceOpen && (
288
- <Button
289
- variant="ghost"
290
- size="sm"
291
- icon={<Info className="size-4" />}
292
- onClick={onToggleIntelligence}
293
- aria-label="Show intelligence sidebar"
294
- />
295
- )}
289
+ <IntelligenceToggle
290
+ open={intelligenceOpen}
291
+ enabled={canToggleIntelligence}
292
+ onToggle={onToggleIntelligence}
293
+ />
296
294
  </MailActionToolbar>
297
295
  );
298
296
  }
@@ -301,12 +299,12 @@ export function ReadingPane({
301
299
  thread,
302
300
  intelligenceOpen,
303
301
  onToggleIntelligence,
304
- showIntelligenceToggle,
302
+ canToggleIntelligence,
305
303
  }: {
306
304
  thread?: ThreadData;
307
305
  intelligenceOpen?: boolean;
308
306
  onToggleIntelligence?: () => void;
309
- showIntelligenceToggle?: boolean;
307
+ canToggleIntelligence?: boolean;
310
308
  }) {
311
309
  return (
312
310
  <article className="flex h-full w-full min-w-0 flex-col bg-canvas">
@@ -314,7 +312,7 @@ export function ReadingPane({
314
312
  hasThread={Boolean(thread)}
315
313
  intelligenceOpen={intelligenceOpen}
316
314
  onToggleIntelligence={onToggleIntelligence}
317
- showIntelligenceToggle={showIntelligenceToggle}
315
+ canToggleIntelligence={canToggleIntelligence}
318
316
  />
319
317
 
320
318
  {!thread ? (
@@ -72,6 +72,44 @@ const resultSections: SearchResultSection[] = [
72
72
  { id: "related", label: "Related", results: related },
73
73
  ];
74
74
 
75
+ /**
76
+ * Matches from outside the inbox — Archive, Sent, Spam and a custom folder.
77
+ * These are the rows an unscoped search returns that an INBOX-only one could
78
+ * not, so they only ever appear under the brief's sections.
79
+ */
80
+ const crossFolderMatches: SearchResult[] = [
81
+ {
82
+ id: "x1",
83
+ sender: "Mollie",
84
+ subject: "Invoice 2026-02 — archived",
85
+ snippet: "Filed to Archive last month; payment already settled.",
86
+ date: "Feb 24",
87
+ category: { label: "Receipt", tone: "positive" },
88
+ },
89
+ {
90
+ id: "x2",
91
+ sender: "me",
92
+ subject: "Re: invoice query",
93
+ snippet: "Sent — attaching the invoice you asked for.",
94
+ date: "Feb 18",
95
+ },
96
+ {
97
+ id: "x3",
98
+ sender: "billing@unknown-vendor.test",
99
+ subject: "URGENT invoice attached",
100
+ snippet: "Marked as spam, but it is the invoice the user is looking for.",
101
+ date: "Feb 11",
102
+ category: { label: "Spam", tone: "warning" },
103
+ },
104
+ {
105
+ id: "x4",
106
+ sender: "Accountant",
107
+ subject: "Invoices for the quarter",
108
+ snippet: "Filed under Projects/Bookkeeping.",
109
+ date: "Jan 30",
110
+ },
111
+ ];
112
+
75
113
  const emptySections: SearchResultSection[] = [
76
114
  { id: "top", label: "Top matches", results: [] },
77
115
  ];
@@ -111,6 +149,50 @@ export const Idle: Story = {
111
149
  render: () => <Harness value="" recentSearches={recentSearches} />,
112
150
  };
113
151
 
152
+ /**
153
+ * The daily brief's unscoped search: no scope chip in the bar, and the literal
154
+ * section carries matches from every folder — Archive, Sent, Spam and custom
155
+ * folders alongside the inbox. Before the listing behind it took a search mode
156
+ * it could only ever return inbox mail, so this section was silently narrower
157
+ * than the bar promised.
158
+ *
159
+ * The rows themselves do not say which folder they came from; the sections are
160
+ * ordered newest first regardless of where each message is filed.
161
+ */
162
+ export const UnscopedAcrossFolders: Story = {
163
+ render: () => (
164
+ <Harness
165
+ value="invoice"
166
+ sections={[
167
+ {
168
+ id: "top",
169
+ label: "Top matches",
170
+ results: [...topMatches, ...crossFolderMatches],
171
+ },
172
+ { id: "related", label: "Related", results: related },
173
+ ]}
174
+ />
175
+ ),
176
+ };
177
+
178
+ /**
179
+ * A scoped view (a mailbox route, its `in:` chip in the bar). Both sections are
180
+ * scoped to that folder and take the kit's default labels — the semantic
181
+ * section used to run unscoped here under an "Everywhere" heading, which
182
+ * contradicted the chip the same bar was showing.
183
+ */
184
+ export const ScopedToOneFolder: Story = {
185
+ render: () => (
186
+ <Harness
187
+ value="invoice"
188
+ sections={[
189
+ { id: "top", label: "Top matches", results: topMatches.slice(0, 2) },
190
+ { id: "related", label: "Related", results: related.slice(0, 1) },
191
+ ]}
192
+ />
193
+ ),
194
+ };
195
+
114
196
  /** Typed filter tokens (`from:`, `has:attachment`, …) render as removable chips above the sections. */
115
197
  export const WithFilterTokens: Story = {
116
198
  render: () => (
package/src/index.ts CHANGED
@@ -144,6 +144,10 @@ export {
144
144
  type SimilarMessageLinkProps,
145
145
  type SimilarState,
146
146
  } from "./components/intelligence-panel.js";
147
+ export {
148
+ IntelligenceToggle,
149
+ type IntelligenceToggleProps,
150
+ } from "./components/intelligence-toggle.js";
147
151
  export {
148
152
  IsolatedEmailFrame,
149
153
  type IsolatedEmailFrameProps,