@remit/ui 0.0.29 → 0.0.31

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.29",
3
+ "version": "0.0.31",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -122,6 +122,18 @@ describe("AppShellSlotted slot rendering", () => {
122
122
  }
123
123
  });
124
124
 
125
+ it("keeps the nav column beside the top bar, not under it", () => {
126
+ const html = render({
127
+ initialWidth: 1400,
128
+ topBar: createElement("div", { "data-testid": "topbar" }, "Bar"),
129
+ reading: createElement("div", { "data-testid": "reading" }, "Reading"),
130
+ });
131
+ assert.ok(
132
+ html.indexOf('data-testid="nav"') < html.indexOf('data-testid="topbar"'),
133
+ "nav opens the layout so the bar starts on the list's edge",
134
+ );
135
+ });
136
+
125
137
  it("omits the top bar when the caller passes none", () => {
126
138
  const html = render({ initialWidth: 1100 });
127
139
  assert.doesNotMatch(html, /data-testid="topbar"/);
@@ -60,10 +60,10 @@ export interface AppShellSlottedProps {
60
60
  */
61
61
  header?: ReactNode;
62
62
  /**
63
- * Full-width row above every pane, including the nav. Unlike `header` it is
64
- * rendered at whatever width the caller mounts it, and it spans the whole
65
- * shell rather than sitting inside a panethat width is what makes the
66
- * search field in it read as the app's search rather than the list's.
63
+ * Row above the list, reading and intelligence panes, spanning all three.
64
+ * The nav column runs the full height beside it and is never covered, so the
65
+ * bar starts on the list's left edgeits span is what makes the search
66
+ * field in it read as the app's search rather than the list's.
67
67
  */
68
68
  topBar?: ReactNode;
69
69
  /**
@@ -176,6 +176,65 @@ export function AppShellSlotted({
176
176
  showIntelligencePane: isWide,
177
177
  };
178
178
 
179
+ /* Sizes are percentages of the group they sit in. This group excludes the nav
180
+ column, so they are shares of the remaining ~83%, not of the whole shell. */
181
+ const contentPanes = (
182
+ <ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
183
+ <ResizablePanel
184
+ id="list"
185
+ order={1}
186
+ defaultSize={showReadingPane ? (density === "compact" ? 43 : 33) : 100}
187
+ minSize={22}
188
+ maxSize={showReadingPane ? 58 : 100}
189
+ className="min-w-0"
190
+ >
191
+ {list}
192
+ </ResizablePanel>
193
+
194
+ {showReadingPane && (
195
+ <>
196
+ <ResizableHandle />
197
+ <ResizablePanel
198
+ id="reading"
199
+ order={2}
200
+ minSize={29}
201
+ className="min-w-0"
202
+ >
203
+ {reading}
204
+ </ResizablePanel>
205
+ </>
206
+ )}
207
+
208
+ {showIntelligencePanel && (
209
+ <>
210
+ <ResizableHandle />
211
+ <ResizablePanel
212
+ id="intelligence"
213
+ order={3}
214
+ defaultSize={25}
215
+ minSize={18}
216
+ maxSize={39}
217
+ className="min-w-0"
218
+ >
219
+ {intelligence}
220
+ </ResizablePanel>
221
+ </>
222
+ )}
223
+ </ResizablePanelGroup>
224
+ );
225
+
226
+ const content = (
227
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col">
228
+ {topBar}
229
+
230
+ {/* Narrow top bar: rendered only when the nav is a slide-over
231
+ (< 1024px). Desktop has no slim bar. */}
232
+ {!showNavPane && header && <div className="shrink-0">{header}</div>}
233
+
234
+ {contentPanes}
235
+ </div>
236
+ );
237
+
179
238
  return (
180
239
  <AppShellLayoutCtx.Provider value={layoutCtx}>
181
240
  <div
@@ -186,75 +245,33 @@ export function AppShellSlotted({
186
245
  skeleton
187
246
  ) : (
188
247
  <>
189
- {topBar}
190
-
191
- {/* Narrow top bar: rendered only when the nav is a slide-over
192
- (< 1024px). Desktop has no slim bar. */}
193
- {!showNavPane && header && <div className="shrink-0">{header}</div>}
194
-
195
- <ResizablePanelGroup
196
- direction="horizontal"
197
- className="min-h-0 flex-1"
198
- >
199
- {showNavPane && (
200
- <>
201
- <ResizablePanel
202
- id="nav"
203
- order={1}
204
- defaultSize={17}
205
- minSize={12}
206
- maxSize={24}
207
- className="min-w-0"
208
- >
209
- {nav}
210
- </ResizablePanel>
211
- <ResizableHandle />
212
- </>
213
- )}
214
-
215
- <ResizablePanel
216
- id="list"
217
- order={2}
218
- defaultSize={
219
- showReadingPane ? (density === "compact" ? 36 : 27) : 83
220
- }
221
- minSize={18}
222
- maxSize={showReadingPane ? 48 : 88}
223
- className="min-w-0"
248
+ {showNavPane ? (
249
+ <ResizablePanelGroup
250
+ direction="horizontal"
251
+ className="min-h-0 flex-1"
224
252
  >
225
- {list}
226
- </ResizablePanel>
227
-
228
- {showReadingPane && (
229
- <>
230
- <ResizableHandle />
231
- <ResizablePanel
232
- id="reading"
233
- order={3}
234
- minSize={24}
235
- className="min-w-0"
236
- >
237
- {reading}
238
- </ResizablePanel>
239
- </>
240
- )}
241
-
242
- {showIntelligencePanel && (
243
- <>
244
- <ResizableHandle />
245
- <ResizablePanel
246
- id="intelligence"
247
- order={4}
248
- defaultSize={21}
249
- minSize={15}
250
- maxSize={32}
251
- className="min-w-0"
252
- >
253
- {intelligence}
254
- </ResizablePanel>
255
- </>
256
- )}
257
- </ResizablePanelGroup>
253
+ <ResizablePanel
254
+ id="nav"
255
+ order={1}
256
+ defaultSize={17}
257
+ minSize={12}
258
+ maxSize={24}
259
+ className="min-w-0"
260
+ >
261
+ {nav}
262
+ </ResizablePanel>
263
+ <ResizableHandle />
264
+ <ResizablePanel
265
+ id="content"
266
+ order={2}
267
+ className="flex min-w-0 flex-col"
268
+ >
269
+ {content}
270
+ </ResizablePanel>
271
+ </ResizablePanelGroup>
272
+ ) : (
273
+ content
274
+ )}
258
275
 
259
276
  {/* Narrow nav: dismissible slide-over (#784). */}
260
277
  {!showNavPane && (
@@ -20,33 +20,27 @@ describe("AppTopBar", () => {
20
20
  assert.match(render(), /data-testid="search"/);
21
21
  });
22
22
 
23
- it("renders the leading and action slots when supplied", () => {
24
- const html = render({
25
- leading: slot("leading"),
26
- actions: slot("actions"),
27
- });
28
- assert.match(html, /data-testid="leading"/);
23
+ it("renders the action slot when supplied", () => {
24
+ const html = render({ actions: slot("actions") });
29
25
  assert.match(html, /data-testid="actions"/);
30
26
  });
31
27
 
32
- it("omits the optional slots entirely rather than leaving empty boxes", () => {
33
- const html = render();
34
- assert.doesNotMatch(html, /data-testid="leading"/);
35
- assert.doesNotMatch(html, /data-testid="actions"/);
28
+ it("omits the actions entirely rather than leaving an empty box", () => {
29
+ assert.doesNotMatch(render(), /data-testid="actions"/);
36
30
  });
37
31
 
38
- it("lays the bar out leading · search · actions", () => {
39
- const html = render({
40
- leading: slot("leading"),
41
- actions: slot("actions"),
42
- });
32
+ it("lays the bar out search · actions", () => {
33
+ const html = render({ actions: slot("actions") });
43
34
  assert.ok(
44
- html.indexOf("leading") < html.indexOf("search") &&
45
- html.indexOf("search") < html.indexOf("actions"),
35
+ html.indexOf("search") < html.indexOf("actions"),
46
36
  "slots render in reading order",
47
37
  );
48
38
  });
49
39
 
40
+ it("carries no brand mark — the bar is search, not a masthead", () => {
41
+ assert.doesNotMatch(render(), /remit/i);
42
+ });
43
+
50
44
  it("is a banner landmark spanning the app, not a pane header", () => {
51
45
  assert.match(render(), /<header/);
52
46
  assert.match(render(), /w-full/);
@@ -1,5 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Bug, Menu, SquarePen } from "lucide-react";
2
+ import { Bug, SquarePen } from "lucide-react";
3
3
  import { useState } from "react";
4
4
  import { AppTopBar } from "./app-top-bar.js";
5
5
  import { Avatar } from "./avatar.js";
@@ -15,20 +15,6 @@ export default meta;
15
15
 
16
16
  type Story = StoryObj<typeof AppTopBar>;
17
17
 
18
- const Brand = () => (
19
- <>
20
- <Button
21
- variant="ghost"
22
- icon={<Menu className="size-5" />}
23
- aria-label="Menu"
24
- className="px-0 lg:hidden"
25
- />
26
- <span className="px-1 text-sm font-semibold tracking-tight text-fg">
27
- remit
28
- </span>
29
- </>
30
- );
31
-
32
18
  const Actions = () => (
33
19
  <>
34
20
  <Button
@@ -74,7 +60,6 @@ const Bar = ({
74
60
  const [value, setValue] = useState("");
75
61
  return (
76
62
  <AppTopBar
77
- leading={<Brand />}
78
63
  actions={<Actions />}
79
64
  search={
80
65
  <SearchChipInput
@@ -98,17 +83,19 @@ const Bar = ({
98
83
 
99
84
  /** Over the panes it spans, so the arrangement reads the way it will in the app. */
100
85
  const WithPanes = ({ children }: { children: React.ReactNode }) => (
101
- <div className="flex h-96 flex-col bg-canvas">
102
- {children}
103
- <div className="flex min-h-0 flex-1">
104
- <div className="w-56 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
105
- Nav
106
- </div>
107
- <div className="w-72 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
108
- Message list
109
- </div>
110
- <div className="min-w-0 flex-1 p-3 text-xs text-fg-muted">
111
- Message pane its own toolbar lives here, under the bar
86
+ <div className="flex h-96 bg-canvas">
87
+ <div className="w-56 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
88
+ Nav full height, beside the bar
89
+ </div>
90
+ <div className="flex min-w-0 flex-1 flex-col">
91
+ {children}
92
+ <div className="flex min-h-0 flex-1">
93
+ <div className="w-72 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
94
+ Message list
95
+ </div>
96
+ <div className="min-w-0 flex-1 p-3 text-xs text-fg-muted">
97
+ Message pane — its own toolbar lives here, under the bar
98
+ </div>
112
99
  </div>
113
100
  </div>
114
101
  </div>
@@ -170,7 +157,8 @@ export const ScopedToOutbox: Story = {
170
157
  ),
171
158
  };
172
159
 
173
- /** The arrangement: one bar over the nav, the list, and the message pane. */
160
+ /** The arrangement: one bar over the list and the message pane, lined up with
161
+ * the list's left edge, with the nav column running the full height beside it. */
174
162
  export const OverTheLayout: Story = {
175
163
  render: () => (
176
164
  <WithPanes>
@@ -2,11 +2,6 @@ import type { ReactNode } from "react";
2
2
  import { cn } from "../lib/cn.js";
3
3
 
4
4
  export interface AppTopBarProps {
5
- /**
6
- * Leading slot — the brand mark, and on narrow widths the nav trigger.
7
- * Sits over the nav column, so keep it to the nav pane's width budget.
8
- */
9
- leading?: ReactNode;
10
5
  /**
11
6
  * The search field. Spans the bar's middle and is the only thing that
12
7
  * grows, so the bar reads as one search surface for the whole app.
@@ -22,23 +17,20 @@ export interface AppTopBarProps {
22
17
  }
23
18
 
24
19
  /**
25
- * The application top bar: one full-width row above every pane, carrying
26
- * search and the global actions.
20
+ * The application top bar: one row over the list, reading and intelligence
21
+ * panes, carrying search and the global actions.
27
22
  *
28
23
  * Search sits here rather than over the message list because it is not the
29
- * list's search — it reads across the whole app, and the bar's width is what
30
- * says so. Leading brand and nav trigger, then the search field taking the
31
- * room it needs, then the global actions.
24
+ * list's search — it reads across the whole app, and the bar's span is what
25
+ * says so. It starts on the list's left edge: the nav column runs the full
26
+ * height beside the bar rather than under it, so the field lines up with the
27
+ * columns it searches. The search field takes the room it needs, then the
28
+ * global actions.
32
29
  *
33
30
  * Presentational and slot-driven; the host supplies the wired field and
34
31
  * action controls.
35
32
  */
36
- export function AppTopBar({
37
- leading,
38
- search,
39
- actions,
40
- className,
41
- }: AppTopBarProps) {
33
+ export function AppTopBar({ search, actions, className }: AppTopBarProps) {
42
34
  return (
43
35
  <header
44
36
  className={cn(
@@ -48,9 +40,6 @@ export function AppTopBar({
48
40
  className,
49
41
  )}
50
42
  >
51
- {leading && (
52
- <div className="flex shrink-0 items-center gap-2">{leading}</div>
53
- )}
54
43
  <div className="flex min-w-0 flex-1 justify-start">
55
44
  <div className="w-full max-w-2xl">{search}</div>
56
45
  </div>
@@ -107,7 +107,15 @@ export const MessageBodyView = ({
107
107
 
108
108
  return (
109
109
  <div className={cn("message-body", className)}>
110
- {blockedImageCount > 0 && renderBlockedNotice?.(blockedImageCount)}
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. */}
114
+ {blockedImageCount > 0 && (
115
+ <div className="message-body-notice">
116
+ {renderBlockedNotice?.(blockedImageCount)}
117
+ </div>
118
+ )}
111
119
 
112
120
  {sanitizedHtml ? (
113
121
  // Email HTML renders inside a sandboxed iframe so its own CSS and any
@@ -232,14 +232,21 @@ export function ExpandedMessage({
232
232
  exactly what the app renders, not a divergent inline-HTML mock
233
233
  (#940). `framed` fixtures map to the newsletter treatment (author
234
234
  colors preserved); the rest render plain. */}
235
- {body ?? (
236
- <MessageBodyView
237
- className="mt-3"
238
- html={message.bodyHtml}
239
- category={message.framed ? "newsletter" : "personal"}
240
- allowImages
241
- />
242
- )}
235
+ {/* On a phone the email runs edge to edge: the negative margin cancels
236
+ this block's `px-2` so no app canvas shows beside the body and the
237
+ email doesn't read as sitting in a tinted frame (#763). The header
238
+ keeps its inset. The blocked-images notice is app chrome, not part
239
+ of the email, so it takes the gutter back. Desktop is unchanged. */}
240
+ <div className="-mx-2 [&_.message-body-notice]:px-2 lg:mx-0 lg:[&_.message-body-notice]:px-0">
241
+ {body ?? (
242
+ <MessageBodyView
243
+ className="mt-3"
244
+ html={message.bodyHtml}
245
+ category={message.framed ? "newsletter" : "personal"}
246
+ allowImages
247
+ />
248
+ )}
249
+ </div>
243
250
  </div>
244
251
  );
245
252
  }