@remit/ui 0.0.88 → 0.0.90
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 +1 -1
- package/src/components/app-shell-slotted.tsx +1 -1
- package/src/components/app-shell-types.ts +9 -1
- package/src/components/auth-card.tsx +1 -1
- package/src/components/bottom-sheet.tsx +1 -1
- package/src/components/dialog.tsx +4 -2
- package/src/components/mobile-message-action-bar.render.test.ts +54 -5
- package/src/components/mobile-message-action-bar.stories.tsx +18 -0
- package/src/components/mobile-message-action-bar.tsx +71 -54
- package/src/components/mobile-reading-pane.render.test.ts +16 -2
- package/src/components/mobile-reading-pane.tsx +4 -0
- package/src/components/pull-to-refresh.tsx +5 -4
- package/src/components/selection-wizard.tsx +1 -1
- package/src/components/settings-screen.tsx +15 -2
- package/src/components/slide-panel.tsx +1 -1
- package/src/components/wizard.tsx +1 -1
- package/src/index.ts +4 -0
- package/src/lib/layout-breakpoints.ts +12 -0
- package/src/tokens.css +26 -0
package/package.json
CHANGED
|
@@ -259,7 +259,7 @@ export function AppShellSlotted({
|
|
|
259
259
|
<AppShellLayoutCtx.Provider value={layoutCtx}>
|
|
260
260
|
<div
|
|
261
261
|
ref={containerRef}
|
|
262
|
-
className="@container flex h-dvh w-full flex-col overflow-hidden bg-canvas font-sans text-fg"
|
|
262
|
+
className="safe-area-frame @container flex h-dvh w-full flex-col overflow-hidden bg-canvas font-sans text-fg"
|
|
263
263
|
>
|
|
264
264
|
{isLoading && skeleton ? (
|
|
265
265
|
skeleton
|
|
@@ -124,7 +124,15 @@ export function useContainerWidth(
|
|
|
124
124
|
if (entry) setWidth(entry.contentRect.width);
|
|
125
125
|
});
|
|
126
126
|
observer.observe(el);
|
|
127
|
-
|
|
127
|
+
// The content box, matching what the observer reports above: the shell
|
|
128
|
+
// root carries the device safe-area insets as padding and the panes are
|
|
129
|
+
// laid out in what is left, so the first synchronous measure and every
|
|
130
|
+
// later one have to be the same box.
|
|
131
|
+
const style = getComputedStyle(el);
|
|
132
|
+
const horizontalPadding =
|
|
133
|
+
Number.parseFloat(style.paddingLeft) +
|
|
134
|
+
Number.parseFloat(style.paddingRight);
|
|
135
|
+
setWidth(el.clientWidth - horizontalPadding);
|
|
128
136
|
return () => observer.disconnect();
|
|
129
137
|
}, []);
|
|
130
138
|
return [ref, width];
|
|
@@ -18,7 +18,7 @@ export function AuthCard({ children, className }: AuthCardProps) {
|
|
|
18
18
|
<div
|
|
19
19
|
data-auth-page
|
|
20
20
|
className={cn(
|
|
21
|
-
"flex min-h-dvh w-full flex-col items-center justify-center overflow-y-auto bg-surface-sunken
|
|
21
|
+
"flex min-h-dvh w-full flex-col items-center justify-center overflow-y-auto bg-surface-sunken pb-[max(2rem,env(safe-area-inset-bottom,0px))] pl-[max(1rem,env(safe-area-inset-left,0px))] pr-[max(1rem,env(safe-area-inset-right,0px))] pt-[max(2rem,env(safe-area-inset-top,0px))] text-fg",
|
|
22
22
|
className,
|
|
23
23
|
)}
|
|
24
24
|
style={{
|
|
@@ -119,7 +119,7 @@ export function BottomSheet({
|
|
|
119
119
|
/>
|
|
120
120
|
<div
|
|
121
121
|
ref={sheetRef}
|
|
122
|
-
className="absolute inset-x-0 bottom-0 flex max-h-[92%] flex-col rounded-t-3xl border-t border-line bg-surface shadow-2xl shadow-black/30"
|
|
122
|
+
className="absolute inset-x-0 bottom-0 flex max-h-[92%] flex-col rounded-t-3xl border-t border-line bg-surface pb-[env(safe-area-inset-bottom,0px)] shadow-2xl shadow-black/30"
|
|
123
123
|
style={{ transform: `translateY(${offset}px)`, transition }}
|
|
124
124
|
>
|
|
125
125
|
<div
|
|
@@ -85,10 +85,12 @@ export function Dialog({
|
|
|
85
85
|
aria-labelledby="dialog-title"
|
|
86
86
|
className={cn(
|
|
87
87
|
"relative z-10 overflow-hidden border-line bg-surface shadow-xl",
|
|
88
|
+
// The edge-anchored panels reach the device edges; the centered
|
|
89
|
+
// card floats clear of them.
|
|
88
90
|
isLeft
|
|
89
|
-
? "h-full w-72 max-w-[85vw] border-r"
|
|
91
|
+
? "safe-area-frame h-full w-72 max-w-[85vw] border-r"
|
|
90
92
|
: isRight
|
|
91
|
-
? "h-full w-[80vw] max-w-[320px] border-l"
|
|
93
|
+
? "safe-area-frame h-full w-[80vw] max-w-[320px] border-l"
|
|
92
94
|
: "w-full max-w-lg rounded-md border",
|
|
93
95
|
className,
|
|
94
96
|
)}
|
|
@@ -4,7 +4,16 @@ import { createElement } from "react";
|
|
|
4
4
|
import { renderToString } from "react-dom/server";
|
|
5
5
|
import { MobileMessageActionBar } from "./mobile-message-action-bar.js";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// A wired host, as both real callers are: the bar offers only the verbs it was
|
|
8
|
+
// given an answer for.
|
|
9
|
+
const base = {
|
|
10
|
+
hasThread: true,
|
|
11
|
+
onReply: () => undefined,
|
|
12
|
+
onReplyAll: () => undefined,
|
|
13
|
+
onForward: () => undefined,
|
|
14
|
+
onToggleStar: () => undefined,
|
|
15
|
+
onDelete: () => undefined,
|
|
16
|
+
} as const;
|
|
8
17
|
|
|
9
18
|
const countMatches = (html: string, needle: string) =>
|
|
10
19
|
html.split(needle).length - 1;
|
|
@@ -64,17 +73,57 @@ describe("MobileMessageActionBar", () => {
|
|
|
64
73
|
|
|
65
74
|
it("renders the per-message reply verbs and no intelligence toggle", () => {
|
|
66
75
|
const html = renderToString(createElement(MobileMessageActionBar, base));
|
|
67
|
-
assert.
|
|
68
|
-
assert.
|
|
69
|
-
assert.
|
|
76
|
+
assert.equal(countMatches(html, 'aria-label="Reply"'), 1);
|
|
77
|
+
assert.equal(countMatches(html, 'aria-label="Reply all"'), 1);
|
|
78
|
+
assert.equal(countMatches(html, 'aria-label="Forward"'), 1);
|
|
70
79
|
assert.doesNotMatch(html, /intelligence panel/);
|
|
71
80
|
});
|
|
72
81
|
|
|
73
|
-
it("
|
|
82
|
+
it("drops a verb the host supplies no handler for", () => {
|
|
83
|
+
const html = renderToString(
|
|
84
|
+
createElement(MobileMessageActionBar, {
|
|
85
|
+
hasThread: true,
|
|
86
|
+
onReply: () => undefined,
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
assert.equal(countMatches(html, 'aria-label="Reply"'), 1);
|
|
90
|
+
assert.doesNotMatch(html, /aria-label="Reply all"/);
|
|
91
|
+
assert.doesNotMatch(html, /aria-label="Forward"/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("never disables a verb it offers", () => {
|
|
74
95
|
const html = renderToString(createElement(MobileMessageActionBar, base));
|
|
96
|
+
assert.equal(countMatches(html, 'aria-label="Reply"'), 1);
|
|
75
97
|
assert.doesNotMatch(html, /disabled=""/);
|
|
76
98
|
});
|
|
77
99
|
|
|
100
|
+
it("keeps the host's verbs pressable with no message open", () => {
|
|
101
|
+
const html = renderToString(
|
|
102
|
+
createElement(MobileMessageActionBar, {
|
|
103
|
+
hasThread: false,
|
|
104
|
+
onReply: () => undefined,
|
|
105
|
+
onReplyAll: () => undefined,
|
|
106
|
+
onForward: () => undefined,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
assert.equal(countMatches(html, 'aria-label="Reply"'), 1);
|
|
110
|
+
assert.equal(countMatches(html, 'aria-label="Reply all"'), 1);
|
|
111
|
+
assert.equal(countMatches(html, 'aria-label="Forward"'), 1);
|
|
112
|
+
assert.doesNotMatch(html, /disabled=""/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("drops an unhandled verb with no message open too", () => {
|
|
116
|
+
const html = renderToString(
|
|
117
|
+
createElement(MobileMessageActionBar, {
|
|
118
|
+
hasThread: false,
|
|
119
|
+
onReply: () => undefined,
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
assert.equal(countMatches(html, 'aria-label="Reply"'), 1);
|
|
123
|
+
assert.doesNotMatch(html, /aria-label="Reply all"/);
|
|
124
|
+
assert.doesNotMatch(html, /aria-label="Forward"/);
|
|
125
|
+
});
|
|
126
|
+
|
|
78
127
|
it("surfaces the unavailable hint when no message is open", () => {
|
|
79
128
|
const html = renderToString(
|
|
80
129
|
createElement(MobileMessageActionBar, {
|
|
@@ -37,6 +37,7 @@ const meta: Meta<typeof MobileMessageActionBar> = {
|
|
|
37
37
|
onToggleStar: () => undefined,
|
|
38
38
|
onDelete: () => undefined,
|
|
39
39
|
onToggleRead: () => undefined,
|
|
40
|
+
onUnavailable: () => undefined,
|
|
40
41
|
},
|
|
41
42
|
};
|
|
42
43
|
export default meta;
|
|
@@ -47,6 +48,12 @@ export const Default: Story = {};
|
|
|
47
48
|
|
|
48
49
|
export const Starred: Story = { args: { isStarred: true } };
|
|
49
50
|
|
|
51
|
+
/** A host that cannot answer a verb omits its handler and the bar drops the
|
|
52
|
+
* button, rather than offering one that reacts to nothing. */
|
|
53
|
+
export const WithoutForward: Story = {
|
|
54
|
+
args: { onForward: undefined },
|
|
55
|
+
};
|
|
56
|
+
|
|
50
57
|
/** No message open: the verbs no-op and the bar surfaces a one-line reason
|
|
51
58
|
* instead of disabling. */
|
|
52
59
|
export const NoMessageOpen: Story = {
|
|
@@ -55,3 +62,14 @@ export const NoMessageOpen: Story = {
|
|
|
55
62
|
unavailableHint: "Open a message first",
|
|
56
63
|
},
|
|
57
64
|
};
|
|
65
|
+
|
|
66
|
+
/** The two rules together: the host owns reply but not forward, and no message
|
|
67
|
+
* is open. Reply stays up and explains itself on press; forward is not there
|
|
68
|
+
* to press. Whether a message is open never decides which verbs exist. */
|
|
69
|
+
export const NoMessageOpenWithoutForward: Story = {
|
|
70
|
+
args: {
|
|
71
|
+
hasThread: false,
|
|
72
|
+
unavailableHint: "Open a message first",
|
|
73
|
+
onForward: undefined,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -24,8 +24,7 @@ export interface MobileMessageActionBarProps {
|
|
|
24
24
|
/** Whether a message is open. Gates the verbs (never disables). */
|
|
25
25
|
hasThread: boolean;
|
|
26
26
|
/* ---- Reply verbs ----
|
|
27
|
-
* Per-message: replying targets the message this bar belongs to.
|
|
28
|
-
* handler to drop that verb. */
|
|
27
|
+
* Per-message: replying targets the message this bar belongs to. */
|
|
29
28
|
onReply?: () => void;
|
|
30
29
|
onReplyAll?: () => void;
|
|
31
30
|
onForward?: () => void;
|
|
@@ -59,8 +58,13 @@ const TOUCH = "min-h-11 min-w-11 px-0";
|
|
|
59
58
|
* repeats it. Intelligence is not here: it lives once in the top app bar and
|
|
60
59
|
* reflects the active message. No archive either — Remit is IMAP-backed and
|
|
61
60
|
* IMAP has no archive concept. Built from the kit `Button`, `PopoverMenu` and a
|
|
62
|
-
* caller-supplied `moveSlot
|
|
63
|
-
*
|
|
61
|
+
* caller-supplied `moveSlot`.
|
|
62
|
+
*
|
|
63
|
+
* One rule for what the bar shows, both halves of it: a verb the host has left
|
|
64
|
+
* unhandled is not part of this surface and is not offered, and a verb the host
|
|
65
|
+
* owns stays pressable with no message open and explains itself through
|
|
66
|
+
* `onUnavailable` rather than greying out. What the bar never does is render a
|
|
67
|
+
* control that answers nothing.
|
|
64
68
|
*/
|
|
65
69
|
export function MobileMessageActionBar({
|
|
66
70
|
hasThread,
|
|
@@ -104,58 +108,71 @@ export function MobileMessageActionBar({
|
|
|
104
108
|
return (
|
|
105
109
|
<div className={cn("relative", className)}>
|
|
106
110
|
<div className="flex h-12 shrink-0 items-center gap-0.5 bg-canvas px-1">
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
111
|
+
{onReply && (
|
|
112
|
+
<Button
|
|
113
|
+
variant="ghost"
|
|
114
|
+
size="sm"
|
|
115
|
+
icon={<Reply className="size-5" />}
|
|
116
|
+
onClick={act("reply", onReply)}
|
|
117
|
+
aria-label="Reply"
|
|
118
|
+
title="Reply"
|
|
119
|
+
className={TOUCH}
|
|
120
|
+
/>
|
|
121
|
+
)}
|
|
122
|
+
{onReplyAll && (
|
|
123
|
+
<Button
|
|
124
|
+
variant="ghost"
|
|
125
|
+
size="sm"
|
|
126
|
+
icon={<ReplyAll className="size-5" />}
|
|
127
|
+
onClick={act("replyAll", onReplyAll)}
|
|
128
|
+
aria-label="Reply all"
|
|
129
|
+
title="Reply all"
|
|
130
|
+
className={TOUCH}
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
{onForward && (
|
|
134
|
+
<Button
|
|
135
|
+
variant="ghost"
|
|
136
|
+
size="sm"
|
|
137
|
+
icon={<Forward className="size-5" />}
|
|
138
|
+
onClick={act("forward", onForward)}
|
|
139
|
+
aria-label="Forward"
|
|
140
|
+
title="Forward"
|
|
141
|
+
className={TOUCH}
|
|
142
|
+
/>
|
|
143
|
+
)}
|
|
134
144
|
<div className="flex-1" />
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
{onToggleStar && (
|
|
146
|
+
<Button
|
|
147
|
+
variant="ghost"
|
|
148
|
+
size="sm"
|
|
149
|
+
icon={
|
|
150
|
+
<Star
|
|
151
|
+
className={cn(
|
|
152
|
+
"size-5",
|
|
153
|
+
isStarred && "fill-warning text-warning",
|
|
154
|
+
)}
|
|
155
|
+
/>
|
|
156
|
+
}
|
|
157
|
+
onClick={act("star", onToggleStar)}
|
|
158
|
+
aria-label={isStarred ? "Unstar" : "Star"}
|
|
159
|
+
aria-pressed={isStarred}
|
|
160
|
+
title={isStarred ? "Unstar" : "Star"}
|
|
161
|
+
className={TOUCH}
|
|
162
|
+
/>
|
|
163
|
+
)}
|
|
149
164
|
{moveSlot}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
165
|
+
{onDelete && (
|
|
166
|
+
<Button
|
|
167
|
+
variant="ghost"
|
|
168
|
+
size="sm"
|
|
169
|
+
icon={<Trash2 className="size-5" />}
|
|
170
|
+
onClick={act("delete", onDelete)}
|
|
171
|
+
aria-label="Move to Trash"
|
|
172
|
+
title="Move to Trash"
|
|
173
|
+
className={TOUCH}
|
|
174
|
+
/>
|
|
175
|
+
)}
|
|
159
176
|
<PopoverMenu
|
|
160
177
|
triggerLabel="More actions"
|
|
161
178
|
items={[...readItem, ...overflowItems]}
|
|
@@ -82,7 +82,16 @@ describe("MobileReadingPane", () => {
|
|
|
82
82
|
|
|
83
83
|
it("shows exactly one per-message action bar — the expanded message's", () => {
|
|
84
84
|
const html = renderToString(
|
|
85
|
-
createElement(MobileReadingPane, {
|
|
85
|
+
createElement(MobileReadingPane, {
|
|
86
|
+
thread,
|
|
87
|
+
onBack: () => undefined,
|
|
88
|
+
// A wired host, as the app is: an unhandled verb is not offered.
|
|
89
|
+
actions: {
|
|
90
|
+
onReply: () => undefined,
|
|
91
|
+
onToggleStar: () => undefined,
|
|
92
|
+
onDelete: () => undefined,
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
86
95
|
);
|
|
87
96
|
// One expanded message ⇒ one Reply / Flag / Trash cluster; the two
|
|
88
97
|
// collapsed rows carry no bar.
|
|
@@ -95,8 +104,13 @@ describe("MobileReadingPane", () => {
|
|
|
95
104
|
// The desktop/legacy footer carries a "Reply (r)" title; the per-message
|
|
96
105
|
// bar titles are plain "Reply". Assert the footer affordance is absent.
|
|
97
106
|
const html = renderToString(
|
|
98
|
-
createElement(MobileReadingPane, {
|
|
107
|
+
createElement(MobileReadingPane, {
|
|
108
|
+
thread,
|
|
109
|
+
onBack: () => undefined,
|
|
110
|
+
actions: { onReply: () => undefined },
|
|
111
|
+
}),
|
|
99
112
|
);
|
|
113
|
+
assert.match(html, /title="Reply"/);
|
|
100
114
|
assert.doesNotMatch(html, /title="Reply \(r\)"/);
|
|
101
115
|
});
|
|
102
116
|
});
|
|
@@ -11,6 +11,10 @@ import {
|
|
|
11
11
|
import { MobileMessageActionBar } from "./mobile-message-action-bar.js";
|
|
12
12
|
import { CollapsedMessage, ExpandedMessage } from "./reading-pane.js";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* What the host answers for a message. A verb left out is not offered on the
|
|
16
|
+
* message's action bar rather than rendered against nothing.
|
|
17
|
+
*/
|
|
14
18
|
export interface MobileReadingMessageActions {
|
|
15
19
|
/** Expand / collapse a message row. */
|
|
16
20
|
onToggleExpand?: (id: string) => void;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactElement } from "react";
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
3
|
import ReactPullToRefresh from "react-simple-pull-to-refresh";
|
|
4
|
+
import { DESKTOP_MEDIA_QUERY } from "../lib/layout-breakpoints.js";
|
|
4
5
|
|
|
5
6
|
export interface PullToRefreshProps {
|
|
6
7
|
children: ReactElement;
|
|
@@ -28,9 +29,9 @@ const useMatchMedia = (query: string): boolean => {
|
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Wraps a scrollable list with a pull-to-refresh gesture on mobile. Below the
|
|
31
|
-
* desktop breakpoint (Tailwind `lg
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
* desktop breakpoint (Tailwind `lg`) a downward pull at the top of the list
|
|
33
|
+
* fires `onRefresh`; at desktop the gesture is inert and children render
|
|
34
|
+
* directly, since there is no touch list to pull.
|
|
34
35
|
*
|
|
35
36
|
* Presentational: the caller owns what refreshing means via `onRefresh` and
|
|
36
37
|
* surfaces in-flight state through `isRefreshing`.
|
|
@@ -40,7 +41,7 @@ export const PullToRefresh = ({
|
|
|
40
41
|
onRefresh,
|
|
41
42
|
isRefreshing,
|
|
42
43
|
}: PullToRefreshProps): ReactElement => {
|
|
43
|
-
const isDesktop = useMatchMedia(
|
|
44
|
+
const isDesktop = useMatchMedia(DESKTOP_MEDIA_QUERY);
|
|
44
45
|
|
|
45
46
|
if (isDesktop) {
|
|
46
47
|
return children;
|
|
@@ -148,7 +148,7 @@ export function WizardScreen({
|
|
|
148
148
|
className="fixed inset-0 z-50 flex flex-col font-sans text-fg md:items-center md:justify-center md:bg-black/40 md:p-6"
|
|
149
149
|
>
|
|
150
150
|
<div className="flex min-h-0 w-full flex-1 flex-col bg-canvas md:h-[45rem] md:max-h-[calc(100dvh-3rem)] md:w-[35rem] md:max-w-[calc(100vw-3rem)] md:flex-none md:overflow-hidden md:rounded-xl md:border md:border-line md:shadow-lg">
|
|
151
|
-
<header className="shrink-0 border-b border-line px-3 pb-2 pt-3">
|
|
151
|
+
<header className="shrink-0 border-b border-line px-3 pb-2 pt-[calc(0.75rem+env(safe-area-inset-top,0px))] md:pt-3">
|
|
152
152
|
<div className="flex items-center gap-1">
|
|
153
153
|
<button
|
|
154
154
|
type="button"
|
|
@@ -102,7 +102,7 @@ export function SettingsShell({
|
|
|
102
102
|
);
|
|
103
103
|
|
|
104
104
|
return (
|
|
105
|
-
<div className="flex h-dvh w-full overflow-hidden bg-canvas font-sans text-fg">
|
|
105
|
+
<div className="safe-area-frame flex h-dvh w-full overflow-hidden bg-canvas font-sans text-fg">
|
|
106
106
|
{/* Desktop nav rail */}
|
|
107
107
|
<aside className="hidden w-60 shrink-0 flex-col border-r border-line bg-surface-sunken lg:flex">
|
|
108
108
|
<div className="flex h-pane-header shrink-0 items-center border-b border-line px-2">
|
|
@@ -127,7 +127,7 @@ export function SettingsShell({
|
|
|
127
127
|
className="absolute inset-0 bg-canvas/60"
|
|
128
128
|
onClick={() => setNavOpen(false)}
|
|
129
129
|
/>
|
|
130
|
-
<aside className="relative flex w-72 max-w-[85%] flex-col border-r border-line bg-surface-sunken">
|
|
130
|
+
<aside className="safe-area-frame relative flex w-72 max-w-[85%] flex-col border-r border-line bg-surface-sunken">
|
|
131
131
|
<div className="flex h-pane-header shrink-0 items-center justify-between border-b border-line px-2">
|
|
132
132
|
<button
|
|
133
133
|
type="button"
|
|
@@ -156,6 +156,19 @@ export function SettingsShell({
|
|
|
156
156
|
<main className="flex min-w-0 flex-1 flex-col">
|
|
157
157
|
{/* pane-header datum: title row, hairline at the shared y */}
|
|
158
158
|
<header className="flex h-pane-header shrink-0 items-center gap-3 border-b border-line px-3 lg:px-5">
|
|
159
|
+
{/* Below desktop this is the only way out of settings that is on
|
|
160
|
+
screen: the rail carrying "Back to mail" is a drawer here, and an
|
|
161
|
+
app launched from a home screen has no browser back button. */}
|
|
162
|
+
{onBackToMail && (
|
|
163
|
+
<Button
|
|
164
|
+
variant="ghost"
|
|
165
|
+
size="sm"
|
|
166
|
+
icon={<ChevronLeft className="size-4" />}
|
|
167
|
+
className="-ml-1 lg:hidden"
|
|
168
|
+
onClick={onBackToMail}
|
|
169
|
+
aria-label="Back to mail"
|
|
170
|
+
/>
|
|
171
|
+
)}
|
|
159
172
|
<Button
|
|
160
173
|
variant="ghost"
|
|
161
174
|
size="sm"
|
|
@@ -54,7 +54,7 @@ export function SlidePanel({
|
|
|
54
54
|
|
|
55
55
|
<div
|
|
56
56
|
className={cn(
|
|
57
|
-
"fixed top-0 right-0 z-50 h-full w-full border-l border-line bg-canvas shadow-xl sm:w-[400px] sm:max-w-[90vw]",
|
|
57
|
+
"safe-area-frame fixed top-0 right-0 z-50 h-full w-full border-l border-line bg-canvas shadow-xl sm:w-[400px] sm:max-w-[90vw]",
|
|
58
58
|
"transform transition-transform duration-200 ease-out",
|
|
59
59
|
isOpen ? "translate-x-0" : "pointer-events-none translate-x-full",
|
|
60
60
|
)}
|
|
@@ -81,7 +81,7 @@ export function WizardShell({
|
|
|
81
81
|
// (optical center) on larger screens. On phone the top padding is
|
|
82
82
|
// minimal so taller steps (connector picker, servers) don't push the
|
|
83
83
|
// CTA bar below the fold — the shell scrolls if content overflows.
|
|
84
|
-
<div className="flex min-h-dvh w-full flex-col items-center overflow-y-auto bg-canvas
|
|
84
|
+
<div className="flex min-h-dvh w-full flex-col items-center overflow-y-auto bg-canvas pb-[max(2rem,env(safe-area-inset-bottom,0px))] pl-[max(2rem,env(safe-area-inset-left,0px))] pr-[max(2rem,env(safe-area-inset-right,0px))] pt-[max(2rem,env(safe-area-inset-top,0px))] font-sans text-fg sm:pt-[30vh]">
|
|
85
85
|
{!hideSteps && (
|
|
86
86
|
<div className="mb-5 w-full max-w-xl">
|
|
87
87
|
<StepRail steps={steps} activeStep={activeStep} />
|
package/src/index.ts
CHANGED
|
@@ -699,6 +699,10 @@ export {
|
|
|
699
699
|
labelColorOptions,
|
|
700
700
|
labelDotClass,
|
|
701
701
|
} from "./lib/label-color.js";
|
|
702
|
+
export {
|
|
703
|
+
DESKTOP_MEDIA_QUERY,
|
|
704
|
+
DESKTOP_MIN_WIDTH,
|
|
705
|
+
} from "./lib/layout-breakpoints.js";
|
|
702
706
|
export {
|
|
703
707
|
derivePropertyClauses,
|
|
704
708
|
normalizeSubject,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const DESKTOP_MIN_WIDTH = 1024;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The desktop gate. Width alone is not enough: a large tablet in portrait is
|
|
5
|
+
* exactly 1024px wide, and the multi-pane shell does not fit a surface held
|
|
6
|
+
* upright. A coarse pointer in portrait therefore never counts as desktop,
|
|
7
|
+
* while a tall monitor (fine pointer) still does.
|
|
8
|
+
*
|
|
9
|
+
* `tokens.css` redefines Tailwind's `lg` variant with this exact condition,
|
|
10
|
+
* so the CSS-gated chrome and anything reading the query in JS flip together.
|
|
11
|
+
*/
|
|
12
|
+
export const DESKTOP_MEDIA_QUERY = `(min-width: ${DESKTOP_MIN_WIDTH}px) and (not ((orientation: portrait) and (pointer: coarse)))`;
|
package/src/tokens.css
CHANGED
|
@@ -11,6 +11,19 @@
|
|
|
11
11
|
.dark on a wrapper; an app would toggle it on <html>. */
|
|
12
12
|
@custom-variant dark (&:is(.dark *));
|
|
13
13
|
|
|
14
|
+
/* Tailwind's built-in lg variant, redefined: desktop chrome is a device
|
|
15
|
+
posture, not a width. A large tablet is exactly 1024px wide in portrait
|
|
16
|
+
and belongs in the single-pane layout, so a coarse pointer held upright
|
|
17
|
+
never reaches lg regardless of width. A tall desktop monitor has a fine
|
|
18
|
+
pointer and keeps it. Every lg: class in the app and the workbench rides
|
|
19
|
+
this rule; it must stay identical to DESKTOP_MEDIA_QUERY in
|
|
20
|
+
src/lib/layout-breakpoints.ts. */
|
|
21
|
+
@custom-variant lg {
|
|
22
|
+
@media (min-width: 1024px) and (not ((orientation: portrait) and (pointer: coarse))) {
|
|
23
|
+
@slot;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
/* @theme inline aliases Tailwind utilities (bg-surface, text-fg, etc.)
|
|
15
28
|
to the semantic CSS variables declared on :root / .dark below. The
|
|
16
29
|
:root / .dark blocks MUST come after this @theme block — otherwise
|
|
@@ -193,6 +206,19 @@ html.dark,
|
|
|
193
206
|
color-scheme: dark;
|
|
194
207
|
}
|
|
195
208
|
|
|
209
|
+
/* Installed to a home screen the app runs without browser chrome, so its own
|
|
210
|
+
surfaces reach the status bar, the home indicator and, in landscape, the
|
|
211
|
+
notch. A screen-owning root carries the device insets; `viewport-fit=cover`
|
|
212
|
+
in the page head is what makes them anything but zero, and in a browser tab
|
|
213
|
+
they stay zero. Sized in the border box, so a root pinned to the viewport
|
|
214
|
+
keeps its height and gives the room out of its content. */
|
|
215
|
+
@utility safe-area-frame {
|
|
216
|
+
padding-top: env(safe-area-inset-top, 0px);
|
|
217
|
+
padding-right: env(safe-area-inset-right, 0px);
|
|
218
|
+
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
219
|
+
padding-left: env(safe-area-inset-left, 0px);
|
|
220
|
+
}
|
|
221
|
+
|
|
196
222
|
body {
|
|
197
223
|
margin: 0;
|
|
198
224
|
background-color: var(--color-canvas);
|