@protolabsai/ui 0.7.0 → 0.8.1
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 +11 -3
- package/src/AppShell.full.stories.tsx +4 -2
- package/src/AppShell.stories.tsx +3 -2
- package/src/Badge.stories.tsx +2 -2
- package/src/Blog.stories.tsx +4 -2
- package/src/Button.stories.tsx +2 -2
- package/src/Content.stories.tsx +4 -2
- package/src/Data.stories.tsx +2 -1
- package/src/Forms.stories.tsx +1 -1
- package/src/Foundations.stories.tsx +15 -2
- package/src/Introduction.stories.tsx +3 -13
- package/src/Menu.stories.tsx +2 -1
- package/src/Overlays.stories.tsx +3 -10
- package/src/Primitives.stories.tsx +2 -2
- package/src/Process.stories.tsx +3 -2
- package/src/Row.stories.tsx +2 -2
- package/src/Skeleton.stories.tsx +2 -2
- package/src/Surface.stories.tsx +3 -2
- package/src/app-shell.tsx +309 -0
- package/src/data.tsx +126 -0
- package/src/forms.tsx +109 -0
- package/src/internal.ts +11 -0
- package/src/layout.tsx +57 -0
- package/src/marketing.tsx +95 -0
- package/src/menu.tsx +141 -0
- package/src/navigation.tsx +94 -0
- package/src/overlays.tsx +299 -0
- package/src/primitives.tsx +90 -0
- package/src/styles/app-shell.css +212 -0
- package/src/styles/data.css +185 -0
- package/src/styles/forms.css +193 -0
- package/src/styles/layout.css +98 -0
- package/src/styles/marketing.css +249 -0
- package/src/styles/menu.css +90 -0
- package/src/styles/navigation.css +173 -0
- package/src/styles/overlays.css +295 -0
- package/src/styles/primitives.css +219 -0
- package/src/styles.css +12 -1510
- package/src/index.tsx +0 -1329
package/src/overlays.tsx
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import type { ReactNode, RefObject } from "react";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
|
|
3
|
+
import { cx } from "./internal";
|
|
4
|
+
import type { Status } from "./internal";
|
|
5
|
+
import { Button } from "./primitives";
|
|
6
|
+
|
|
7
|
+
/** Esc-to-close + body scroll-lock while `open`. Shared by Dialog + Drawer. */
|
|
8
|
+
function useOverlayDismiss(open: boolean, onClose?: () => void) {
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!open) return;
|
|
11
|
+
const onKey = (e: KeyboardEvent) => {
|
|
12
|
+
if (e.key === "Escape") onClose?.();
|
|
13
|
+
};
|
|
14
|
+
document.addEventListener("keydown", onKey);
|
|
15
|
+
const prev = document.body.style.overflow;
|
|
16
|
+
document.body.style.overflow = "hidden";
|
|
17
|
+
return () => {
|
|
18
|
+
document.removeEventListener("keydown", onKey);
|
|
19
|
+
document.body.style.overflow = prev;
|
|
20
|
+
};
|
|
21
|
+
}, [open, onClose]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Move focus into `ref` on open and cycle Tab within it (a11y modal trap). */
|
|
25
|
+
function useFocusTrap(ref: RefObject<HTMLElement | null>, open: boolean) {
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const node = ref.current;
|
|
28
|
+
if (!open || !node) return;
|
|
29
|
+
const focusables = () =>
|
|
30
|
+
Array.from(
|
|
31
|
+
node.querySelectorAll<HTMLElement>(
|
|
32
|
+
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])',
|
|
33
|
+
),
|
|
34
|
+
).filter((el) => el.offsetParent !== null);
|
|
35
|
+
(focusables()[0] ?? node).focus();
|
|
36
|
+
const onKey = (e: KeyboardEvent) => {
|
|
37
|
+
if (e.key !== "Tab") return;
|
|
38
|
+
const items = focusables();
|
|
39
|
+
if (items.length === 0) return;
|
|
40
|
+
const first = items[0];
|
|
41
|
+
const last = items[items.length - 1];
|
|
42
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
last.focus();
|
|
45
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
first.focus();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
node.addEventListener("keydown", onKey);
|
|
51
|
+
return () => node.removeEventListener("keydown", onKey);
|
|
52
|
+
}, [ref, open]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Modal dialog — scrim + centered card, Esc / backdrop close, focus trap.
|
|
56
|
+
* Controlled: render with `open` and handle `onClose`. */
|
|
57
|
+
export function Dialog({
|
|
58
|
+
open,
|
|
59
|
+
onClose,
|
|
60
|
+
title,
|
|
61
|
+
children,
|
|
62
|
+
footer,
|
|
63
|
+
width,
|
|
64
|
+
className,
|
|
65
|
+
}: {
|
|
66
|
+
open: boolean;
|
|
67
|
+
onClose?: () => void;
|
|
68
|
+
title?: ReactNode;
|
|
69
|
+
children?: ReactNode;
|
|
70
|
+
/** Action row pinned to the dialog foot (e.g. Cancel / Confirm buttons). */
|
|
71
|
+
footer?: ReactNode;
|
|
72
|
+
width?: number | string;
|
|
73
|
+
className?: string;
|
|
74
|
+
}) {
|
|
75
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
76
|
+
const labelId = useId();
|
|
77
|
+
useOverlayDismiss(open, onClose);
|
|
78
|
+
useFocusTrap(ref, open);
|
|
79
|
+
if (!open) return null;
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
className="pl-overlay"
|
|
83
|
+
onMouseDown={(e) => {
|
|
84
|
+
if (e.target === e.currentTarget) onClose?.();
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<div
|
|
88
|
+
ref={ref}
|
|
89
|
+
className={cx("pl-dialog", className)}
|
|
90
|
+
role="dialog"
|
|
91
|
+
aria-modal="true"
|
|
92
|
+
aria-labelledby={title != null ? labelId : undefined}
|
|
93
|
+
tabIndex={-1}
|
|
94
|
+
style={width != null ? { width, maxWidth: "100%" } : undefined}
|
|
95
|
+
>
|
|
96
|
+
{title != null && (
|
|
97
|
+
<div className="pl-dialog__head">
|
|
98
|
+
<div className="pl-dialog__title" id={labelId}>
|
|
99
|
+
{title}
|
|
100
|
+
</div>
|
|
101
|
+
{onClose && (
|
|
102
|
+
<button type="button" className="pl-dialog__close" aria-label="Close" onClick={onClose}>
|
|
103
|
+
×
|
|
104
|
+
</button>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
{children != null && <div className="pl-dialog__body">{children}</div>}
|
|
109
|
+
{footer != null && <div className="pl-dialog__foot">{footer}</div>}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Destructive-confirm convenience over Dialog. `destructive` reddens confirm. */
|
|
116
|
+
export function ConfirmDialog({
|
|
117
|
+
open,
|
|
118
|
+
title,
|
|
119
|
+
children,
|
|
120
|
+
confirmLabel = "Confirm",
|
|
121
|
+
cancelLabel = "Cancel",
|
|
122
|
+
destructive,
|
|
123
|
+
onConfirm,
|
|
124
|
+
onClose,
|
|
125
|
+
}: {
|
|
126
|
+
open: boolean;
|
|
127
|
+
title?: ReactNode;
|
|
128
|
+
children?: ReactNode;
|
|
129
|
+
confirmLabel?: ReactNode;
|
|
130
|
+
cancelLabel?: ReactNode;
|
|
131
|
+
destructive?: boolean;
|
|
132
|
+
onConfirm?: () => void;
|
|
133
|
+
onClose?: () => void;
|
|
134
|
+
}) {
|
|
135
|
+
return (
|
|
136
|
+
<Dialog
|
|
137
|
+
open={open}
|
|
138
|
+
onClose={onClose}
|
|
139
|
+
title={title}
|
|
140
|
+
width={420}
|
|
141
|
+
footer={
|
|
142
|
+
<>
|
|
143
|
+
<Button onClick={onClose}>{cancelLabel}</Button>
|
|
144
|
+
<Button variant={destructive ? "danger" : "primary"} onClick={() => onConfirm?.()}>
|
|
145
|
+
{confirmLabel}
|
|
146
|
+
</Button>
|
|
147
|
+
</>
|
|
148
|
+
}
|
|
149
|
+
>
|
|
150
|
+
{children}
|
|
151
|
+
</Dialog>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Slide-in side panel / sheet. Esc / backdrop close, focus trap. */
|
|
156
|
+
export function Drawer({
|
|
157
|
+
open,
|
|
158
|
+
onClose,
|
|
159
|
+
side = "right",
|
|
160
|
+
title,
|
|
161
|
+
children,
|
|
162
|
+
footer,
|
|
163
|
+
width,
|
|
164
|
+
className,
|
|
165
|
+
}: {
|
|
166
|
+
open: boolean;
|
|
167
|
+
onClose?: () => void;
|
|
168
|
+
side?: "left" | "right";
|
|
169
|
+
title?: ReactNode;
|
|
170
|
+
children?: ReactNode;
|
|
171
|
+
footer?: ReactNode;
|
|
172
|
+
width?: number | string;
|
|
173
|
+
className?: string;
|
|
174
|
+
}) {
|
|
175
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
176
|
+
const labelId = useId();
|
|
177
|
+
useOverlayDismiss(open, onClose);
|
|
178
|
+
useFocusTrap(ref, open);
|
|
179
|
+
if (!open) return null;
|
|
180
|
+
return (
|
|
181
|
+
<div
|
|
182
|
+
className="pl-overlay pl-overlay--drawer"
|
|
183
|
+
onMouseDown={(e) => {
|
|
184
|
+
if (e.target === e.currentTarget) onClose?.();
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
<div
|
|
188
|
+
ref={ref}
|
|
189
|
+
className={cx("pl-drawer", `pl-drawer--${side}`, className)}
|
|
190
|
+
role="dialog"
|
|
191
|
+
aria-modal="true"
|
|
192
|
+
aria-labelledby={title != null ? labelId : undefined}
|
|
193
|
+
tabIndex={-1}
|
|
194
|
+
style={width != null ? { width, maxWidth: "100%" } : undefined}
|
|
195
|
+
>
|
|
196
|
+
{title != null && (
|
|
197
|
+
<div className="pl-drawer__head">
|
|
198
|
+
<div className="pl-drawer__title" id={labelId}>
|
|
199
|
+
{title}
|
|
200
|
+
</div>
|
|
201
|
+
{onClose && (
|
|
202
|
+
<button type="button" className="pl-dialog__close" aria-label="Close" onClick={onClose}>
|
|
203
|
+
×
|
|
204
|
+
</button>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
<div className="pl-drawer__body">{children}</div>
|
|
209
|
+
{footer != null && <div className="pl-drawer__foot">{footer}</div>}
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
type ToastOptions = { tone?: Status; title?: ReactNode; message: ReactNode; duration?: number };
|
|
216
|
+
type ToastItem = Required<Pick<ToastOptions, "tone" | "message" | "duration">> & {
|
|
217
|
+
id: string;
|
|
218
|
+
title?: ReactNode;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const ToastContext = createContext<((opts: ToastOptions) => string) | null>(null);
|
|
222
|
+
|
|
223
|
+
/** Wrap the app once. Exposes `useToast()` to push transient notifications. */
|
|
224
|
+
export function ToastProvider({ children, max = 4 }: { children: ReactNode; max?: number }) {
|
|
225
|
+
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
226
|
+
const seq = useRef(0);
|
|
227
|
+
const dismiss = useCallback((id: string) => setToasts((ts) => ts.filter((t) => t.id !== id)), []);
|
|
228
|
+
const toast = useCallback(
|
|
229
|
+
(opts: ToastOptions) => {
|
|
230
|
+
const id = `t${(seq.current += 1)}`;
|
|
231
|
+
const item: ToastItem = {
|
|
232
|
+
id,
|
|
233
|
+
tone: opts.tone ?? "neutral",
|
|
234
|
+
title: opts.title,
|
|
235
|
+
message: opts.message,
|
|
236
|
+
duration: opts.duration ?? 4000,
|
|
237
|
+
};
|
|
238
|
+
setToasts((ts) => [...ts.slice(-(max - 1)), item]);
|
|
239
|
+
return id;
|
|
240
|
+
},
|
|
241
|
+
[max],
|
|
242
|
+
);
|
|
243
|
+
return (
|
|
244
|
+
<ToastContext.Provider value={toast}>
|
|
245
|
+
{children}
|
|
246
|
+
<div className="pl-toast-stack" role="region" aria-label="Notifications">
|
|
247
|
+
{toasts.map((t) => (
|
|
248
|
+
<ToastView key={t.id} toast={t} onDismiss={dismiss} />
|
|
249
|
+
))}
|
|
250
|
+
</div>
|
|
251
|
+
</ToastContext.Provider>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function ToastView({ toast, onDismiss }: { toast: ToastItem; onDismiss: (id: string) => void }) {
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
if (toast.duration <= 0) return;
|
|
258
|
+
const h = setTimeout(() => onDismiss(toast.id), toast.duration);
|
|
259
|
+
return () => clearTimeout(h);
|
|
260
|
+
}, [toast, onDismiss]);
|
|
261
|
+
return (
|
|
262
|
+
<div className={cx("pl-toast", toast.tone !== "neutral" && `pl-toast--${toast.tone}`)} role="status">
|
|
263
|
+
<div className="pl-toast__body">
|
|
264
|
+
{toast.title != null && <div className="pl-toast__title">{toast.title}</div>}
|
|
265
|
+
<div className="pl-toast__msg">{toast.message}</div>
|
|
266
|
+
</div>
|
|
267
|
+
<button type="button" className="pl-toast__close" aria-label="Dismiss" onClick={() => onDismiss(toast.id)}>
|
|
268
|
+
×
|
|
269
|
+
</button>
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Returns `toast(opts)` — call to push a notification. Throws outside provider. */
|
|
275
|
+
export function useToast() {
|
|
276
|
+
const ctx = useContext(ToastContext);
|
|
277
|
+
if (!ctx) throw new Error("useToast must be used within a <ToastProvider>");
|
|
278
|
+
return ctx;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** CSS-only hover/focus tooltip. Wrap the trigger; `label` is the bubble. */
|
|
282
|
+
export function Tooltip({
|
|
283
|
+
label,
|
|
284
|
+
side = "top",
|
|
285
|
+
children,
|
|
286
|
+
}: {
|
|
287
|
+
label: ReactNode;
|
|
288
|
+
side?: "top" | "bottom" | "left" | "right";
|
|
289
|
+
children: ReactNode;
|
|
290
|
+
}) {
|
|
291
|
+
return (
|
|
292
|
+
<span className="pl-tip-wrap">
|
|
293
|
+
{children}
|
|
294
|
+
<span className={cx("pl-tip", `pl-tip--${side}`)} role="tooltip">
|
|
295
|
+
{label}
|
|
296
|
+
</span>
|
|
297
|
+
</span>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
import { cx } from "./internal";
|
|
3
|
+
import type { Status } from "./internal";
|
|
4
|
+
|
|
5
|
+
/** The shared status tone, re-exported here as its canonical public home. */
|
|
6
|
+
export type { Status } from "./internal";
|
|
7
|
+
|
|
8
|
+
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
9
|
+
/** "primary"/"danger" read as a stronger border, not a fill (brand restraint);
|
|
10
|
+
* "ghost" is transparent until hover. */
|
|
11
|
+
variant?: "default" | "primary" | "ghost" | "danger";
|
|
12
|
+
size?: "sm" | "md";
|
|
13
|
+
/** Icon-only: square, centered glyph. Pass `aria-label` for a11y. */
|
|
14
|
+
icon?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export function Button({ variant = "default", size = "md", icon, className, ...rest }: ButtonProps) {
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
className={cx(
|
|
20
|
+
"pl-btn",
|
|
21
|
+
variant !== "default" && `pl-btn--${variant}`,
|
|
22
|
+
size === "sm" && "pl-btn--sm",
|
|
23
|
+
icon && "pl-btn--icon",
|
|
24
|
+
className,
|
|
25
|
+
)}
|
|
26
|
+
{...rest}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function Badge({ status = "neutral", children }: { status?: Status; children: ReactNode }) {
|
|
32
|
+
return <span className={cx("pl-badge", status !== "neutral" && `pl-badge--${status}`)}>{children}</span>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function Card({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
36
|
+
return <div className={cx("pl-card", className)} {...rest} />;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function Eyebrow({ children }: { children: ReactNode }) {
|
|
40
|
+
return <div className="pl-eyebrow">{children}</div>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Mono empty-state line. */
|
|
44
|
+
export function Empty({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
45
|
+
return <div className={cx("pl-empty", className)} {...rest} />;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Hairline rule. */
|
|
49
|
+
export function Divider({ className, ...rest }: HTMLAttributes<HTMLHRElement>) {
|
|
50
|
+
return <hr className={cx("pl-divider", className)} {...rest} />;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Bordered note block — left-accent keyed to the status tone. */
|
|
54
|
+
export function Callout({
|
|
55
|
+
tone = "neutral",
|
|
56
|
+
title,
|
|
57
|
+
children,
|
|
58
|
+
}: {
|
|
59
|
+
tone?: Status;
|
|
60
|
+
title?: ReactNode;
|
|
61
|
+
children: ReactNode;
|
|
62
|
+
}) {
|
|
63
|
+
return (
|
|
64
|
+
<div className={cx("pl-callout", tone !== "neutral" && `pl-callout--${tone}`)}>
|
|
65
|
+
{title != null && <div className="pl-callout__title">{title}</div>}
|
|
66
|
+
<div className="pl-callout__body">{children}</div>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Keyboard / inline-token chip. */
|
|
72
|
+
export function Kbd({ children }: { children: ReactNode }) {
|
|
73
|
+
return <kbd className="pl-kbd">{children}</kbd>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Standalone styled link (underline-offset treatment). Use the app's router
|
|
77
|
+
* Link for internal navigation; this is for plain anchors. */
|
|
78
|
+
export function TextLink({
|
|
79
|
+
className,
|
|
80
|
+
external,
|
|
81
|
+
...rest
|
|
82
|
+
}: HTMLAttributes<HTMLAnchorElement> & { href?: string; external?: boolean }) {
|
|
83
|
+
return (
|
|
84
|
+
<a
|
|
85
|
+
className={cx("pl-link", className)}
|
|
86
|
+
{...(external ? { target: "_blank", rel: "noreferrer noopener" } : {})}
|
|
87
|
+
{...rest}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/* @protolabsai/ui — app-shell styles (over @protolabsai/design --pl-* tokens). */
|
|
2
|
+
|
|
3
|
+
/* ── SurfaceRail (icon rail) ─────────────────────────────────────────────────── */
|
|
4
|
+
.pl-rail {
|
|
5
|
+
display: flex;
|
|
6
|
+
flex-direction: column;
|
|
7
|
+
align-items: stretch;
|
|
8
|
+
gap: 2px;
|
|
9
|
+
width: 64px;
|
|
10
|
+
flex-shrink: 0;
|
|
11
|
+
padding: 6px;
|
|
12
|
+
background: var(--pl-color-bg-raised);
|
|
13
|
+
border-right: var(--pl-border-width) solid var(--pl-color-border);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.pl-rail--right {
|
|
17
|
+
border-right: none;
|
|
18
|
+
border-left: var(--pl-border-width) solid var(--pl-color-border);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.pl-rail__btn {
|
|
22
|
+
position: relative;
|
|
23
|
+
display: flex;
|
|
24
|
+
flex-direction: column;
|
|
25
|
+
align-items: center;
|
|
26
|
+
gap: 3px;
|
|
27
|
+
padding: 8px 2px;
|
|
28
|
+
background: none;
|
|
29
|
+
border: none;
|
|
30
|
+
border-radius: var(--pl-radius);
|
|
31
|
+
color: var(--pl-color-fg-muted);
|
|
32
|
+
cursor: pointer;
|
|
33
|
+
font: inherit;
|
|
34
|
+
transition:
|
|
35
|
+
background var(--pl-motion-fast) var(--pl-motion-ease),
|
|
36
|
+
color var(--pl-motion-fast) var(--pl-motion-ease);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.pl-rail__btn:hover {
|
|
40
|
+
background: var(--pl-color-bg-hover);
|
|
41
|
+
color: var(--pl-color-fg);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.pl-rail__btn--active {
|
|
45
|
+
background: var(--pl-color-bg-subtle);
|
|
46
|
+
color: var(--pl-color-fg);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.pl-rail__icon {
|
|
50
|
+
display: inline-flex;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.pl-rail__icon svg {
|
|
54
|
+
width: 18px;
|
|
55
|
+
height: 18px;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.pl-rail__label {
|
|
59
|
+
max-width: 100%;
|
|
60
|
+
overflow: hidden;
|
|
61
|
+
font-size: 9px;
|
|
62
|
+
line-height: 1.1;
|
|
63
|
+
text-align: center;
|
|
64
|
+
text-overflow: ellipsis;
|
|
65
|
+
white-space: nowrap;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.pl-rail__badge {
|
|
69
|
+
position: absolute;
|
|
70
|
+
top: 4px;
|
|
71
|
+
right: 9px;
|
|
72
|
+
display: inline-flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
justify-content: center;
|
|
75
|
+
min-width: 15px;
|
|
76
|
+
height: 15px;
|
|
77
|
+
padding: 0 4px;
|
|
78
|
+
font-family: var(--pl-font-mono);
|
|
79
|
+
font-size: 9px;
|
|
80
|
+
color: var(--pl-color-fg);
|
|
81
|
+
background: var(--pl-color-bg-subtle);
|
|
82
|
+
border: var(--pl-border-width) solid var(--pl-color-border-strong);
|
|
83
|
+
border-radius: 999px;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.pl-rail__dot {
|
|
87
|
+
position: absolute;
|
|
88
|
+
top: 6px;
|
|
89
|
+
right: 14px;
|
|
90
|
+
width: 7px;
|
|
91
|
+
height: 7px;
|
|
92
|
+
border-radius: 50%;
|
|
93
|
+
background: var(--pl-color-status-info);
|
|
94
|
+
animation: pl-dot-pulse var(--pl-motion-status) var(--pl-motion-ease-in-out) infinite;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* ── MobileNav (bottom bar + drawer list) ────────────────────────────────────── */
|
|
98
|
+
.pl-mobilenav {
|
|
99
|
+
display: flex;
|
|
100
|
+
align-items: stretch;
|
|
101
|
+
background: var(--pl-color-bg-raised);
|
|
102
|
+
border-top: var(--pl-border-width) solid var(--pl-color-border);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.pl-mobilenav__tab {
|
|
106
|
+
flex: 1;
|
|
107
|
+
display: flex;
|
|
108
|
+
flex-direction: column;
|
|
109
|
+
align-items: center;
|
|
110
|
+
gap: 3px;
|
|
111
|
+
padding: 8px 2px;
|
|
112
|
+
background: none;
|
|
113
|
+
border: none;
|
|
114
|
+
color: var(--pl-color-fg-muted);
|
|
115
|
+
font: inherit;
|
|
116
|
+
font-size: 10px;
|
|
117
|
+
cursor: pointer;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.pl-mobilenav__tab--active {
|
|
121
|
+
color: var(--pl-color-fg);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.pl-mobilenav__icon {
|
|
125
|
+
display: inline-flex;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.pl-mobilenav__icon svg {
|
|
129
|
+
width: 18px;
|
|
130
|
+
height: 18px;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.pl-mobilenav__list {
|
|
134
|
+
display: grid;
|
|
135
|
+
gap: 2px;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.pl-mobilenav__list-item {
|
|
139
|
+
display: flex;
|
|
140
|
+
align-items: center;
|
|
141
|
+
gap: 10px;
|
|
142
|
+
width: 100%;
|
|
143
|
+
padding: 10px 8px;
|
|
144
|
+
background: none;
|
|
145
|
+
border: none;
|
|
146
|
+
border-radius: var(--pl-radius);
|
|
147
|
+
color: var(--pl-color-fg);
|
|
148
|
+
font: inherit;
|
|
149
|
+
font-size: 14px;
|
|
150
|
+
text-align: left;
|
|
151
|
+
cursor: pointer;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.pl-mobilenav__list-item:hover {
|
|
155
|
+
background: var(--pl-color-bg-hover);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.pl-mobilenav__list-item--active {
|
|
159
|
+
background: var(--pl-color-bg-subtle);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* ── AppShell (dual-rail + 3-column) ─────────────────────────────────────────── */
|
|
163
|
+
.pl-appshell {
|
|
164
|
+
display: flex;
|
|
165
|
+
align-items: stretch;
|
|
166
|
+
height: 100%;
|
|
167
|
+
min-height: 0;
|
|
168
|
+
background: var(--pl-color-bg);
|
|
169
|
+
color: var(--pl-color-fg);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.pl-appshell__col {
|
|
173
|
+
display: flex;
|
|
174
|
+
flex-direction: column;
|
|
175
|
+
min-width: 0;
|
|
176
|
+
min-height: 0;
|
|
177
|
+
overflow: hidden;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.pl-appshell__col--left {
|
|
181
|
+
flex: 1 1 auto;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.pl-appshell__col--right {
|
|
185
|
+
flex: 0 0 auto;
|
|
186
|
+
border-left: var(--pl-border-width) solid var(--pl-color-border);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.pl-appshell__handle {
|
|
190
|
+
flex: 0 0 auto;
|
|
191
|
+
width: 5px;
|
|
192
|
+
cursor: col-resize;
|
|
193
|
+
background: transparent;
|
|
194
|
+
touch-action: none;
|
|
195
|
+
transition: background var(--pl-motion-fast) var(--pl-motion-ease);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.pl-appshell__handle:hover,
|
|
199
|
+
.pl-appshell__handle:focus-visible {
|
|
200
|
+
background: var(--pl-color-border-strong);
|
|
201
|
+
outline: none;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.pl-appshell--mobile {
|
|
205
|
+
flex-direction: column;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.pl-appshell__mobile-stage {
|
|
209
|
+
flex: 1 1 auto;
|
|
210
|
+
min-height: 0;
|
|
211
|
+
overflow: auto;
|
|
212
|
+
}
|