@protolabsai/ui 0.5.0 → 0.7.0
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/AppShell.full.stories.tsx +128 -0
- package/src/Button.stories.tsx +46 -1
- package/src/Skeleton.stories.tsx +37 -0
- package/src/index.tsx +375 -12
- package/src/styles.css +260 -0
package/package.json
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { AppShell, Button, MobileNav, PanelHeader, SurfaceRail } from "./index";
|
|
4
|
+
import type { MobileItem, RailItem } from "./index";
|
|
5
|
+
|
|
6
|
+
const meta: Meta = { title: "Components/AppShell" };
|
|
7
|
+
export default meta;
|
|
8
|
+
type Story = StoryObj;
|
|
9
|
+
|
|
10
|
+
const G = () => (
|
|
11
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
12
|
+
<rect x="2.5" y="2.5" width="11" height="11" rx="2" />
|
|
13
|
+
</svg>
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const LEFT: RailItem[] = [
|
|
17
|
+
{ id: "chat", label: "Chat", icon: <G />, dot: true },
|
|
18
|
+
{ id: "activity", label: "Activity", icon: <G />, badge: 12 },
|
|
19
|
+
{ id: "flows", label: "Flows", icon: <G /> },
|
|
20
|
+
{ id: "knowledge", label: "Know", icon: <G /> },
|
|
21
|
+
];
|
|
22
|
+
const RIGHT: RailItem[] = [
|
|
23
|
+
{ id: "inbox", label: "Inbox", icon: <G />, badge: 3 },
|
|
24
|
+
{ id: "telemetry", label: "Stats", icon: <G /> },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const surfaceBody = (label: string) => (
|
|
28
|
+
<div style={{ flex: 1, padding: 16, color: "var(--pl-color-fg-muted)", fontSize: 13, fontFamily: "var(--pl-font-mono)" }}>
|
|
29
|
+
{label} surface content…
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export const Full: Story = {
|
|
34
|
+
render: () => {
|
|
35
|
+
function Demo() {
|
|
36
|
+
const [activeLeft, setActiveLeft] = useState("chat");
|
|
37
|
+
const [activeRight, setActiveRight] = useState("inbox");
|
|
38
|
+
const [rightWidth, setRightWidth] = useState(360);
|
|
39
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
40
|
+
const mobile: MobileItem[] = [...LEFT, ...RIGHT].map((i) => ({ id: i.id, label: i.label, icon: i.icon }));
|
|
41
|
+
return (
|
|
42
|
+
<div style={{ height: 480, border: "1px solid var(--pl-color-border)", borderRadius: 4, overflow: "hidden" }}>
|
|
43
|
+
<AppShell
|
|
44
|
+
leftItems={LEFT}
|
|
45
|
+
rightItems={RIGHT}
|
|
46
|
+
activeLeft={activeLeft}
|
|
47
|
+
activeRight={activeRight}
|
|
48
|
+
onSelect={(side, id) => {
|
|
49
|
+
if (side === "left") setActiveLeft(id);
|
|
50
|
+
else {
|
|
51
|
+
setActiveRight(id);
|
|
52
|
+
setCollapsed(false);
|
|
53
|
+
}
|
|
54
|
+
}}
|
|
55
|
+
rightWidth={rightWidth}
|
|
56
|
+
onRightWidthChange={setRightWidth}
|
|
57
|
+
rightCollapsed={collapsed}
|
|
58
|
+
onCollapse={setCollapsed}
|
|
59
|
+
leftContent={
|
|
60
|
+
<>
|
|
61
|
+
<PanelHeader title={activeLeft} actions={<Button size="sm">Action</Button>} />
|
|
62
|
+
{surfaceBody(activeLeft)}
|
|
63
|
+
</>
|
|
64
|
+
}
|
|
65
|
+
rightContent={
|
|
66
|
+
<>
|
|
67
|
+
<PanelHeader
|
|
68
|
+
compact
|
|
69
|
+
title={activeRight}
|
|
70
|
+
actions={
|
|
71
|
+
<Button size="sm" variant="ghost" onClick={() => setCollapsed(true)}>
|
|
72
|
+
Collapse
|
|
73
|
+
</Button>
|
|
74
|
+
}
|
|
75
|
+
/>
|
|
76
|
+
{surfaceBody(activeRight)}
|
|
77
|
+
</>
|
|
78
|
+
}
|
|
79
|
+
mobileItems={mobile}
|
|
80
|
+
mobileActiveId={activeLeft}
|
|
81
|
+
onMobileSelect={setActiveLeft}
|
|
82
|
+
quickBarIds={["chat", "activity", "flows", "inbox", "telemetry"]}
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return <Demo />;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const Rail: Story = {
|
|
92
|
+
render: () => {
|
|
93
|
+
function Demo() {
|
|
94
|
+
const [active, setActive] = useState("activity");
|
|
95
|
+
return (
|
|
96
|
+
<div style={{ height: 320, width: 64, border: "1px solid var(--pl-color-border)", borderRadius: 4 }}>
|
|
97
|
+
<SurfaceRail
|
|
98
|
+
side="left"
|
|
99
|
+
ariaLabel="Surfaces"
|
|
100
|
+
items={LEFT}
|
|
101
|
+
activeId={active}
|
|
102
|
+
onSelect={setActive}
|
|
103
|
+
onContextMenu={(e) => e.preventDefault()}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return <Demo />;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const Mobile: Story = {
|
|
113
|
+
render: () => {
|
|
114
|
+
function Demo() {
|
|
115
|
+
const [active, setActive] = useState("chat");
|
|
116
|
+
const mobile: MobileItem[] = [...LEFT, ...RIGHT].map((i) => ({ id: i.id, label: i.label, icon: i.icon }));
|
|
117
|
+
return (
|
|
118
|
+
<div style={{ width: 360, border: "1px solid var(--pl-color-border)", borderRadius: 4 }}>
|
|
119
|
+
<div style={{ height: 220, padding: 16, color: "var(--pl-color-fg-muted)", fontFamily: "var(--pl-font-mono)", fontSize: 13 }}>
|
|
120
|
+
{active} surface — tap “More” for the full list
|
|
121
|
+
</div>
|
|
122
|
+
<MobileNav items={mobile} activeId={active} onSelect={setActive} quickBarIds={["chat", "activity", "flows", "inbox"]} />
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return <Demo />;
|
|
127
|
+
},
|
|
128
|
+
};
|
package/src/Button.stories.tsx
CHANGED
|
@@ -5,7 +5,7 @@ const meta: Meta<typeof Button> = {
|
|
|
5
5
|
title: "Components/Button",
|
|
6
6
|
component: Button,
|
|
7
7
|
args: { children: "Breakdowns" },
|
|
8
|
-
argTypes: { variant: { control: "inline-radio", options: ["default", "primary"] } },
|
|
8
|
+
argTypes: { variant: { control: "inline-radio", options: ["default", "primary", "ghost", "danger"] } },
|
|
9
9
|
};
|
|
10
10
|
export default meta;
|
|
11
11
|
type Story = StoryObj<typeof Button>;
|
|
@@ -13,3 +13,48 @@ type Story = StoryObj<typeof Button>;
|
|
|
13
13
|
export const Default: Story = {};
|
|
14
14
|
export const Primary: Story = { args: { variant: "primary", children: "Get started" } };
|
|
15
15
|
export const Disabled: Story = { args: { disabled: true, children: "Disabled" } };
|
|
16
|
+
|
|
17
|
+
const Sq = () => (
|
|
18
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
19
|
+
<path d="M8 3v10M3 8h10" strokeLinecap="round" />
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export const Variants: Story = {
|
|
24
|
+
render: () => (
|
|
25
|
+
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
|
26
|
+
<Button>default</Button>
|
|
27
|
+
<Button variant="primary">primary</Button>
|
|
28
|
+
<Button variant="ghost">ghost</Button>
|
|
29
|
+
<Button variant="danger">danger</Button>
|
|
30
|
+
</div>
|
|
31
|
+
),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const Sizes: Story = {
|
|
35
|
+
render: () => (
|
|
36
|
+
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
37
|
+
<Button size="sm">small</Button>
|
|
38
|
+
<Button>medium</Button>
|
|
39
|
+
<Button size="sm" variant="primary">
|
|
40
|
+
small primary
|
|
41
|
+
</Button>
|
|
42
|
+
</div>
|
|
43
|
+
),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const IconOnly: Story = {
|
|
47
|
+
render: () => (
|
|
48
|
+
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
49
|
+
<Button icon aria-label="Add">
|
|
50
|
+
<Sq />
|
|
51
|
+
</Button>
|
|
52
|
+
<Button icon variant="ghost" aria-label="Add">
|
|
53
|
+
<Sq />
|
|
54
|
+
</Button>
|
|
55
|
+
<Button icon size="sm" aria-label="Add">
|
|
56
|
+
<Sq />
|
|
57
|
+
</Button>
|
|
58
|
+
</div>
|
|
59
|
+
),
|
|
60
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Skeleton, SkeletonGroup } from "./index";
|
|
3
|
+
|
|
4
|
+
const meta: Meta = { title: "Components/Skeleton" };
|
|
5
|
+
export default meta;
|
|
6
|
+
type Story = StoryObj;
|
|
7
|
+
|
|
8
|
+
export const Bars: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<div style={{ display: "grid", gap: 12, maxWidth: 360 }}>
|
|
11
|
+
<Skeleton />
|
|
12
|
+
<Skeleton width={200} />
|
|
13
|
+
<Skeleton width={120} height={28} />
|
|
14
|
+
</div>
|
|
15
|
+
),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const TextLines: Story = {
|
|
19
|
+
render: () => (
|
|
20
|
+
<div style={{ maxWidth: 360 }}>
|
|
21
|
+
<Skeleton lines={4} />
|
|
22
|
+
</div>
|
|
23
|
+
),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const PanelSkeleton: Story = {
|
|
27
|
+
render: () => (
|
|
28
|
+
<SkeletonGroup style={{ maxWidth: 420, padding: 16, border: "1px solid var(--pl-color-border)", borderRadius: 4 }}>
|
|
29
|
+
<Skeleton width={140} height={16} />
|
|
30
|
+
<Skeleton lines={3} />
|
|
31
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
32
|
+
<Skeleton width={80} height={28} />
|
|
33
|
+
<Skeleton width={80} height={28} />
|
|
34
|
+
</div>
|
|
35
|
+
</SkeletonGroup>
|
|
36
|
+
),
|
|
37
|
+
};
|
package/src/index.tsx
CHANGED
|
@@ -7,6 +7,9 @@ import type {
|
|
|
7
7
|
ButtonHTMLAttributes,
|
|
8
8
|
HTMLAttributes,
|
|
9
9
|
InputHTMLAttributes,
|
|
10
|
+
KeyboardEvent as ReactKeyboardEvent,
|
|
11
|
+
MouseEvent as ReactMouseEvent,
|
|
12
|
+
PointerEvent as ReactPointerEvent,
|
|
10
13
|
ReactNode,
|
|
11
14
|
RefObject,
|
|
12
15
|
SelectHTMLAttributes,
|
|
@@ -32,11 +35,26 @@ import "./styles.css";
|
|
|
32
35
|
const cx = (...parts: Array<string | false | undefined>) => parts.filter(Boolean).join(" ");
|
|
33
36
|
|
|
34
37
|
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
35
|
-
/** "primary"
|
|
36
|
-
|
|
38
|
+
/** "primary"/"danger" read as a stronger border, not a fill (brand restraint);
|
|
39
|
+
* "ghost" is transparent until hover. */
|
|
40
|
+
variant?: "default" | "primary" | "ghost" | "danger";
|
|
41
|
+
size?: "sm" | "md";
|
|
42
|
+
/** Icon-only: square, centered glyph. Pass `aria-label` for a11y. */
|
|
43
|
+
icon?: boolean;
|
|
37
44
|
};
|
|
38
|
-
export function Button({ variant = "default", className, ...rest }: ButtonProps) {
|
|
39
|
-
return
|
|
45
|
+
export function Button({ variant = "default", size = "md", icon, className, ...rest }: ButtonProps) {
|
|
46
|
+
return (
|
|
47
|
+
<button
|
|
48
|
+
className={cx(
|
|
49
|
+
"pl-btn",
|
|
50
|
+
variant !== "default" && `pl-btn--${variant}`,
|
|
51
|
+
size === "sm" && "pl-btn--sm",
|
|
52
|
+
icon && "pl-btn--icon",
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
55
|
+
{...rest}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
40
58
|
}
|
|
41
59
|
|
|
42
60
|
export type Status = "neutral" | "success" | "warning" | "error" | "info";
|
|
@@ -515,11 +533,7 @@ export function ConfirmDialog({
|
|
|
515
533
|
footer={
|
|
516
534
|
<>
|
|
517
535
|
<Button onClick={onClose}>{cancelLabel}</Button>
|
|
518
|
-
<Button
|
|
519
|
-
variant="primary"
|
|
520
|
-
className={cx(destructive && "pl-btn--danger")}
|
|
521
|
-
onClick={() => onConfirm?.()}
|
|
522
|
-
>
|
|
536
|
+
<Button variant={destructive ? "danger" : "primary"} onClick={() => onConfirm?.()}>
|
|
523
537
|
{confirmLabel}
|
|
524
538
|
</Button>
|
|
525
539
|
</>
|
|
@@ -745,9 +759,16 @@ export function Spinner({ size = 16, className }: { size?: number; className?: s
|
|
|
745
759
|
);
|
|
746
760
|
}
|
|
747
761
|
|
|
748
|
-
/** Scroll container with brand-styled thin scrollbars.
|
|
749
|
-
|
|
750
|
-
|
|
762
|
+
/** Scroll container with brand-styled thin scrollbars. Carries `min-height:0`
|
|
763
|
+
* (so it scrolls inside flex/grid parents) + overscroll containment. Pass
|
|
764
|
+
* `ariaLabel` to make it a keyboard-focusable, labelled scroll region. */
|
|
765
|
+
export function ScrollArea({
|
|
766
|
+
ariaLabel,
|
|
767
|
+
className,
|
|
768
|
+
...rest
|
|
769
|
+
}: HTMLAttributes<HTMLDivElement> & { ariaLabel?: string }) {
|
|
770
|
+
const a11y = ariaLabel != null ? { role: "region", "aria-label": ariaLabel, tabIndex: 0 } : {};
|
|
771
|
+
return <div className={cx("pl-scroll", className)} {...a11y} {...rest} />;
|
|
751
772
|
}
|
|
752
773
|
|
|
753
774
|
// ── Form controls (compose with Field, or use standalone) ────────────────────
|
|
@@ -964,3 +985,345 @@ export function MenuSub({
|
|
|
964
985
|
</RDropdown.Sub>
|
|
965
986
|
);
|
|
966
987
|
}
|
|
988
|
+
|
|
989
|
+
// ── Skeleton (loading placeholder) ───────────────────────────────────────────
|
|
990
|
+
|
|
991
|
+
/** Shimmering content-shaped loading placeholder. `lines>1` stacks text bars
|
|
992
|
+
* (last one short). Token-driven; static fill under reduced-motion. */
|
|
993
|
+
export function Skeleton({
|
|
994
|
+
width,
|
|
995
|
+
height = 14,
|
|
996
|
+
lines,
|
|
997
|
+
className,
|
|
998
|
+
style,
|
|
999
|
+
...rest
|
|
1000
|
+
}: HTMLAttributes<HTMLDivElement> & {
|
|
1001
|
+
width?: number | string;
|
|
1002
|
+
height?: number | string;
|
|
1003
|
+
/** Stack N text-line bars instead of a single bar. */
|
|
1004
|
+
lines?: number;
|
|
1005
|
+
}) {
|
|
1006
|
+
if (lines != null && lines > 1) {
|
|
1007
|
+
return (
|
|
1008
|
+
<div className={cx("pl-skel-lines", className)} style={style} {...rest}>
|
|
1009
|
+
{Array.from({ length: lines }, (_, i) => (
|
|
1010
|
+
<div
|
|
1011
|
+
key={i}
|
|
1012
|
+
className="pl-skel"
|
|
1013
|
+
style={{ height, width: i === lines - 1 ? "65%" : (width ?? "100%") }}
|
|
1014
|
+
/>
|
|
1015
|
+
))}
|
|
1016
|
+
</div>
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
return (
|
|
1020
|
+
<div className={cx("pl-skel", className)} style={{ width: width ?? "100%", height, ...style }} {...rest} />
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/** Optional wrapper to group related skeletons (shared layout gap). */
|
|
1025
|
+
export function SkeletonGroup({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
1026
|
+
return <div className={cx("pl-skel-group", className)} {...rest} />;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// ── App shell (SurfaceRail · MobileNav · AppShell) ───────────────────────────
|
|
1030
|
+
// The operator-console shell, converged from protoAgent's production ADR 0035
|
|
1031
|
+
// dual-rail layout. All three are dumb + props-driven; persistence (rail order,
|
|
1032
|
+
// widths, active surfaces) stays app-side — AppShell is controlled.
|
|
1033
|
+
|
|
1034
|
+
export type RailItem = {
|
|
1035
|
+
id: string;
|
|
1036
|
+
label: string;
|
|
1037
|
+
icon: ReactNode;
|
|
1038
|
+
/** Count badge (caps at "9+"). Mutually exclusive with `dot`. */
|
|
1039
|
+
badge?: number;
|
|
1040
|
+
/** Pulsing indicator, no count (e.g. a background stream). */
|
|
1041
|
+
dot?: boolean;
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
/** Vertical icon rail — both the left and right rails render through this.
|
|
1045
|
+
* `onContextMenu` is the integration point for the DS `Menu` (right-click →
|
|
1046
|
+
* host calls menuRef.open({x,y})); the menu's registry/keying stays app-side. */
|
|
1047
|
+
export function SurfaceRail({
|
|
1048
|
+
side,
|
|
1049
|
+
ariaLabel,
|
|
1050
|
+
items,
|
|
1051
|
+
activeId,
|
|
1052
|
+
onSelect,
|
|
1053
|
+
onContextMenu,
|
|
1054
|
+
}: {
|
|
1055
|
+
side: "left" | "right";
|
|
1056
|
+
ariaLabel: string;
|
|
1057
|
+
items: RailItem[];
|
|
1058
|
+
activeId: string;
|
|
1059
|
+
onSelect: (id: string) => void;
|
|
1060
|
+
onContextMenu?: (e: ReactMouseEvent, id: string) => void;
|
|
1061
|
+
}) {
|
|
1062
|
+
return (
|
|
1063
|
+
<aside className={cx("pl-rail", side === "right" && "pl-rail--right")} aria-label={ariaLabel}>
|
|
1064
|
+
{items.map((it) => (
|
|
1065
|
+
<button
|
|
1066
|
+
key={it.id}
|
|
1067
|
+
type="button"
|
|
1068
|
+
className={cx("pl-rail__btn", it.id === activeId && "pl-rail__btn--active")}
|
|
1069
|
+
title={it.label}
|
|
1070
|
+
aria-label={it.label}
|
|
1071
|
+
aria-current={it.id === activeId ? "page" : undefined}
|
|
1072
|
+
onClick={() => onSelect(it.id)}
|
|
1073
|
+
onContextMenu={onContextMenu ? (e) => onContextMenu(e, it.id) : undefined}
|
|
1074
|
+
>
|
|
1075
|
+
<span className="pl-rail__icon" aria-hidden>
|
|
1076
|
+
{it.icon}
|
|
1077
|
+
</span>
|
|
1078
|
+
<span className="pl-rail__label">{it.label}</span>
|
|
1079
|
+
{it.badge ? (
|
|
1080
|
+
<span className="pl-rail__badge">{it.badge > 9 ? "9+" : it.badge}</span>
|
|
1081
|
+
) : it.dot ? (
|
|
1082
|
+
<span className="pl-rail__dot" aria-label="active" />
|
|
1083
|
+
) : null}
|
|
1084
|
+
</button>
|
|
1085
|
+
))}
|
|
1086
|
+
</aside>
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
export type MobileItem = { id: string; label: string; icon: ReactNode };
|
|
1091
|
+
|
|
1092
|
+
/** Mobile shell (<768px): a bottom quick-bar (first 5 pinned surfaces) + a
|
|
1093
|
+
* "More" button that opens the full surface list in a DS `Drawer`. */
|
|
1094
|
+
export function MobileNav({
|
|
1095
|
+
items,
|
|
1096
|
+
activeId,
|
|
1097
|
+
onSelect,
|
|
1098
|
+
quickBarIds,
|
|
1099
|
+
}: {
|
|
1100
|
+
items: MobileItem[];
|
|
1101
|
+
activeId: string;
|
|
1102
|
+
onSelect: (id: string) => void;
|
|
1103
|
+
/** Surfaces pinned to the bottom bar (first 5 used). */
|
|
1104
|
+
quickBarIds: string[];
|
|
1105
|
+
}) {
|
|
1106
|
+
const [open, setOpen] = useState(false);
|
|
1107
|
+
const byId = new Map(items.map((i) => [i.id, i] as const));
|
|
1108
|
+
const quick = quickBarIds
|
|
1109
|
+
.map((id) => byId.get(id))
|
|
1110
|
+
.filter((i): i is MobileItem => Boolean(i))
|
|
1111
|
+
.slice(0, 5);
|
|
1112
|
+
const pick = (id: string) => {
|
|
1113
|
+
onSelect(id);
|
|
1114
|
+
setOpen(false);
|
|
1115
|
+
};
|
|
1116
|
+
return (
|
|
1117
|
+
<>
|
|
1118
|
+
<nav className="pl-mobilenav" aria-label="Quick surfaces">
|
|
1119
|
+
{quick.map((it) => (
|
|
1120
|
+
<button
|
|
1121
|
+
key={it.id}
|
|
1122
|
+
type="button"
|
|
1123
|
+
className={cx("pl-mobilenav__tab", it.id === activeId && "pl-mobilenav__tab--active")}
|
|
1124
|
+
onClick={() => pick(it.id)}
|
|
1125
|
+
>
|
|
1126
|
+
<span className="pl-mobilenav__icon" aria-hidden>
|
|
1127
|
+
{it.icon}
|
|
1128
|
+
</span>
|
|
1129
|
+
<span>{it.label}</span>
|
|
1130
|
+
</button>
|
|
1131
|
+
))}
|
|
1132
|
+
<button type="button" className="pl-mobilenav__tab" onClick={() => setOpen(true)} aria-label="All surfaces">
|
|
1133
|
+
<span className="pl-mobilenav__icon" aria-hidden>
|
|
1134
|
+
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
1135
|
+
<path d="M3 6h18M3 12h18M3 18h18" />
|
|
1136
|
+
</svg>
|
|
1137
|
+
</span>
|
|
1138
|
+
<span>More</span>
|
|
1139
|
+
</button>
|
|
1140
|
+
</nav>
|
|
1141
|
+
<Drawer open={open} onClose={() => setOpen(false)} side="right" title="Surfaces" width={280}>
|
|
1142
|
+
<div className="pl-mobilenav__list">
|
|
1143
|
+
{items.map((it) => (
|
|
1144
|
+
<button
|
|
1145
|
+
key={it.id}
|
|
1146
|
+
type="button"
|
|
1147
|
+
className={cx("pl-mobilenav__list-item", it.id === activeId && "pl-mobilenav__list-item--active")}
|
|
1148
|
+
onClick={() => pick(it.id)}
|
|
1149
|
+
>
|
|
1150
|
+
<span className="pl-mobilenav__icon" aria-hidden>
|
|
1151
|
+
{it.icon}
|
|
1152
|
+
</span>
|
|
1153
|
+
<span>{it.label}</span>
|
|
1154
|
+
</button>
|
|
1155
|
+
))}
|
|
1156
|
+
</div>
|
|
1157
|
+
</Drawer>
|
|
1158
|
+
</>
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** True below `breakpoint` px (client-only; false on first paint). */
|
|
1163
|
+
function useIsMobile(breakpoint: number) {
|
|
1164
|
+
const [mobile, setMobile] = useState(false);
|
|
1165
|
+
useEffect(() => {
|
|
1166
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
1167
|
+
const mq = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
|
|
1168
|
+
const update = () => setMobile(mq.matches);
|
|
1169
|
+
update();
|
|
1170
|
+
mq.addEventListener("change", update);
|
|
1171
|
+
return () => mq.removeEventListener("change", update);
|
|
1172
|
+
}, [breakpoint]);
|
|
1173
|
+
return mobile;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
export type AppShellProps = {
|
|
1177
|
+
leftItems: RailItem[];
|
|
1178
|
+
rightItems: RailItem[];
|
|
1179
|
+
activeLeft: string;
|
|
1180
|
+
activeRight: string;
|
|
1181
|
+
onSelect: (side: "left" | "right", id: string) => void;
|
|
1182
|
+
/** Right-click on a rail icon — wire to a DS `Menu` for move/reorder etc. */
|
|
1183
|
+
onRailContextMenu?: (side: "left" | "right", e: ReactMouseEvent, id: string) => void;
|
|
1184
|
+
leftContent: ReactNode;
|
|
1185
|
+
rightContent: ReactNode;
|
|
1186
|
+
/** Controlled right-column width (px). */
|
|
1187
|
+
rightWidth: number;
|
|
1188
|
+
onRightWidthChange: (width: number) => void;
|
|
1189
|
+
rightCollapsed?: boolean;
|
|
1190
|
+
onCollapse?: (collapsed: boolean) => void;
|
|
1191
|
+
minRightWidth?: number;
|
|
1192
|
+
maxRightWidth?: number;
|
|
1193
|
+
/** Mobile (<breakpoint) config. Omit to disable the mobile shell. */
|
|
1194
|
+
mobileItems?: MobileItem[];
|
|
1195
|
+
mobileActiveId?: string;
|
|
1196
|
+
onMobileSelect?: (id: string) => void;
|
|
1197
|
+
quickBarIds?: string[];
|
|
1198
|
+
mobileBreakpoint?: number;
|
|
1199
|
+
className?: string;
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
/** The full dual-rail operator shell:
|
|
1203
|
+
* `[left rail][left column][resize handle][right column][right rail]`,
|
|
1204
|
+
* collapsing to `MobileNav` below `mobileBreakpoint`. Controlled — the host
|
|
1205
|
+
* owns the surface registry and persists rail order / widths / active state. */
|
|
1206
|
+
export function AppShell({
|
|
1207
|
+
leftItems,
|
|
1208
|
+
rightItems,
|
|
1209
|
+
activeLeft,
|
|
1210
|
+
activeRight,
|
|
1211
|
+
onSelect,
|
|
1212
|
+
onRailContextMenu,
|
|
1213
|
+
leftContent,
|
|
1214
|
+
rightContent,
|
|
1215
|
+
rightWidth,
|
|
1216
|
+
onRightWidthChange,
|
|
1217
|
+
rightCollapsed = false,
|
|
1218
|
+
onCollapse,
|
|
1219
|
+
minRightWidth = 280,
|
|
1220
|
+
maxRightWidth = 720,
|
|
1221
|
+
mobileItems,
|
|
1222
|
+
mobileActiveId,
|
|
1223
|
+
onMobileSelect,
|
|
1224
|
+
quickBarIds,
|
|
1225
|
+
mobileBreakpoint = 768,
|
|
1226
|
+
className,
|
|
1227
|
+
}: AppShellProps) {
|
|
1228
|
+
const isMobile = useIsMobile(mobileBreakpoint);
|
|
1229
|
+
const drag = useRef<{ startX: number; startW: number } | null>(null);
|
|
1230
|
+
const clamp = useCallback(
|
|
1231
|
+
(w: number) => Math.min(maxRightWidth, Math.max(minRightWidth, w)),
|
|
1232
|
+
[minRightWidth, maxRightWidth],
|
|
1233
|
+
);
|
|
1234
|
+
|
|
1235
|
+
const onPointerDown = useCallback(
|
|
1236
|
+
(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
1237
|
+
e.preventDefault();
|
|
1238
|
+
drag.current = { startX: e.clientX, startW: rightWidth };
|
|
1239
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
1240
|
+
},
|
|
1241
|
+
[rightWidth],
|
|
1242
|
+
);
|
|
1243
|
+
const onPointerMove = useCallback(
|
|
1244
|
+
(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
1245
|
+
if (!drag.current) return;
|
|
1246
|
+
onRightWidthChange(clamp(drag.current.startW + (drag.current.startX - e.clientX)));
|
|
1247
|
+
},
|
|
1248
|
+
[clamp, onRightWidthChange],
|
|
1249
|
+
);
|
|
1250
|
+
const onPointerUp = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
|
1251
|
+
drag.current = null;
|
|
1252
|
+
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
1253
|
+
}, []);
|
|
1254
|
+
const onKeyDown = useCallback(
|
|
1255
|
+
(e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
1256
|
+
const step = e.shiftKey ? 48 : 16;
|
|
1257
|
+
if (e.key === "ArrowLeft") {
|
|
1258
|
+
e.preventDefault();
|
|
1259
|
+
onRightWidthChange(clamp(rightWidth + step));
|
|
1260
|
+
} else if (e.key === "ArrowRight") {
|
|
1261
|
+
e.preventDefault();
|
|
1262
|
+
onRightWidthChange(clamp(rightWidth - step));
|
|
1263
|
+
}
|
|
1264
|
+
},
|
|
1265
|
+
[rightWidth, clamp, onRightWidthChange],
|
|
1266
|
+
);
|
|
1267
|
+
|
|
1268
|
+
if (isMobile && mobileItems && onMobileSelect && quickBarIds) {
|
|
1269
|
+
return (
|
|
1270
|
+
<div className={cx("pl-appshell", "pl-appshell--mobile", className)}>
|
|
1271
|
+
<div className="pl-appshell__mobile-stage">{leftContent}</div>
|
|
1272
|
+
<MobileNav
|
|
1273
|
+
items={mobileItems}
|
|
1274
|
+
activeId={mobileActiveId ?? activeLeft}
|
|
1275
|
+
onSelect={onMobileSelect}
|
|
1276
|
+
quickBarIds={quickBarIds}
|
|
1277
|
+
/>
|
|
1278
|
+
</div>
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const showRight = !rightCollapsed && rightItems.length > 0;
|
|
1283
|
+
return (
|
|
1284
|
+
<div className={cx("pl-appshell", className)}>
|
|
1285
|
+
<SurfaceRail
|
|
1286
|
+
side="left"
|
|
1287
|
+
ariaLabel="Left surfaces"
|
|
1288
|
+
items={leftItems}
|
|
1289
|
+
activeId={activeLeft}
|
|
1290
|
+
onSelect={(id) => onSelect("left", id)}
|
|
1291
|
+
onContextMenu={onRailContextMenu ? (e, id) => onRailContextMenu("left", e, id) : undefined}
|
|
1292
|
+
/>
|
|
1293
|
+
<main className="pl-appshell__col pl-appshell__col--left">{leftContent}</main>
|
|
1294
|
+
{showRight && (
|
|
1295
|
+
<div
|
|
1296
|
+
className="pl-appshell__handle"
|
|
1297
|
+
role="separator"
|
|
1298
|
+
aria-orientation="vertical"
|
|
1299
|
+
aria-label="Resize right panel"
|
|
1300
|
+
aria-valuenow={rightWidth}
|
|
1301
|
+
aria-valuemin={minRightWidth}
|
|
1302
|
+
aria-valuemax={maxRightWidth}
|
|
1303
|
+
tabIndex={0}
|
|
1304
|
+
onPointerDown={onPointerDown}
|
|
1305
|
+
onPointerMove={onPointerMove}
|
|
1306
|
+
onPointerUp={onPointerUp}
|
|
1307
|
+
onKeyDown={onKeyDown}
|
|
1308
|
+
onDoubleClick={() => onCollapse?.(true)}
|
|
1309
|
+
/>
|
|
1310
|
+
)}
|
|
1311
|
+
{showRight && (
|
|
1312
|
+
<aside
|
|
1313
|
+
className="pl-appshell__col pl-appshell__col--right"
|
|
1314
|
+
style={{ flex: `0 0 ${rightWidth}px`, width: rightWidth }}
|
|
1315
|
+
>
|
|
1316
|
+
{rightContent}
|
|
1317
|
+
</aside>
|
|
1318
|
+
)}
|
|
1319
|
+
<SurfaceRail
|
|
1320
|
+
side="right"
|
|
1321
|
+
ariaLabel="Right surfaces"
|
|
1322
|
+
items={rightItems}
|
|
1323
|
+
activeId={activeRight}
|
|
1324
|
+
onSelect={(id) => onSelect("right", id)}
|
|
1325
|
+
onContextMenu={onRailContextMenu ? (e, id) => onRailContextMenu("right", e, id) : undefined}
|
|
1326
|
+
/>
|
|
1327
|
+
</div>
|
|
1328
|
+
);
|
|
1329
|
+
}
|
package/src/styles.css
CHANGED
|
@@ -734,6 +734,33 @@ a.pl-row:hover {
|
|
|
734
734
|
border-color: var(--pl-color-status-error);
|
|
735
735
|
color: var(--pl-color-bg);
|
|
736
736
|
}
|
|
737
|
+
/* Ghost — transparent until hover, for toolbars / inline actions. */
|
|
738
|
+
.pl-btn--ghost {
|
|
739
|
+
border-color: transparent;
|
|
740
|
+
}
|
|
741
|
+
.pl-btn--ghost:hover {
|
|
742
|
+
border-color: var(--pl-color-border-strong);
|
|
743
|
+
background: var(--pl-color-bg-hover);
|
|
744
|
+
}
|
|
745
|
+
.pl-btn--sm {
|
|
746
|
+
padding: 0.3rem 0.6rem;
|
|
747
|
+
font-size: 12px;
|
|
748
|
+
}
|
|
749
|
+
/* Icon-only — square, centered glyph. */
|
|
750
|
+
.pl-btn--icon {
|
|
751
|
+
padding: 0;
|
|
752
|
+
width: 30px;
|
|
753
|
+
height: 30px;
|
|
754
|
+
justify-content: center;
|
|
755
|
+
}
|
|
756
|
+
.pl-btn--icon.pl-btn--sm {
|
|
757
|
+
width: 26px;
|
|
758
|
+
height: 26px;
|
|
759
|
+
}
|
|
760
|
+
.pl-btn--icon svg {
|
|
761
|
+
width: 16px;
|
|
762
|
+
height: 16px;
|
|
763
|
+
}
|
|
737
764
|
|
|
738
765
|
/* ── Drawer (slide-in sheet) ─────────────────────────────────────────────────── */
|
|
739
766
|
.pl-drawer {
|
|
@@ -1007,9 +1034,18 @@ a.pl-row:hover {
|
|
|
1007
1034
|
/* ── ScrollArea (thin brand scrollbars) ──────────────────────────────────────── */
|
|
1008
1035
|
.pl-scroll {
|
|
1009
1036
|
overflow: auto;
|
|
1037
|
+
/* min-height:0 lets the region actually scroll inside a flex/grid parent
|
|
1038
|
+
(the classic min-size trap) instead of overflowing it. */
|
|
1039
|
+
min-height: 0;
|
|
1040
|
+
overscroll-behavior: contain;
|
|
1041
|
+
scrollbar-gutter: stable;
|
|
1010
1042
|
scrollbar-width: thin;
|
|
1011
1043
|
scrollbar-color: var(--pl-color-border-strong) transparent;
|
|
1012
1044
|
}
|
|
1045
|
+
.pl-scroll:focus-visible {
|
|
1046
|
+
outline: 1px solid var(--pl-color-fg);
|
|
1047
|
+
outline-offset: -2px;
|
|
1048
|
+
}
|
|
1013
1049
|
.pl-scroll::-webkit-scrollbar {
|
|
1014
1050
|
width: 8px;
|
|
1015
1051
|
height: 8px;
|
|
@@ -1239,12 +1275,236 @@ a.pl-row:hover {
|
|
|
1239
1275
|
color: var(--pl-color-fg-muted);
|
|
1240
1276
|
}
|
|
1241
1277
|
|
|
1278
|
+
/* ── SurfaceRail (icon rail) ─────────────────────────────────────────────────── */
|
|
1279
|
+
.pl-rail {
|
|
1280
|
+
display: flex;
|
|
1281
|
+
flex-direction: column;
|
|
1282
|
+
align-items: stretch;
|
|
1283
|
+
gap: 2px;
|
|
1284
|
+
width: 64px;
|
|
1285
|
+
flex-shrink: 0;
|
|
1286
|
+
padding: 6px;
|
|
1287
|
+
background: var(--pl-color-bg-raised);
|
|
1288
|
+
border-right: var(--pl-border-width) solid var(--pl-color-border);
|
|
1289
|
+
}
|
|
1290
|
+
.pl-rail--right {
|
|
1291
|
+
border-right: none;
|
|
1292
|
+
border-left: var(--pl-border-width) solid var(--pl-color-border);
|
|
1293
|
+
}
|
|
1294
|
+
.pl-rail__btn {
|
|
1295
|
+
position: relative;
|
|
1296
|
+
display: flex;
|
|
1297
|
+
flex-direction: column;
|
|
1298
|
+
align-items: center;
|
|
1299
|
+
gap: 3px;
|
|
1300
|
+
padding: 8px 2px;
|
|
1301
|
+
background: none;
|
|
1302
|
+
border: none;
|
|
1303
|
+
border-radius: var(--pl-radius);
|
|
1304
|
+
color: var(--pl-color-fg-muted);
|
|
1305
|
+
cursor: pointer;
|
|
1306
|
+
font: inherit;
|
|
1307
|
+
transition:
|
|
1308
|
+
background var(--pl-motion-fast) var(--pl-motion-ease),
|
|
1309
|
+
color var(--pl-motion-fast) var(--pl-motion-ease);
|
|
1310
|
+
}
|
|
1311
|
+
.pl-rail__btn:hover {
|
|
1312
|
+
background: var(--pl-color-bg-hover);
|
|
1313
|
+
color: var(--pl-color-fg);
|
|
1314
|
+
}
|
|
1315
|
+
.pl-rail__btn--active {
|
|
1316
|
+
background: var(--pl-color-bg-subtle);
|
|
1317
|
+
color: var(--pl-color-fg);
|
|
1318
|
+
}
|
|
1319
|
+
.pl-rail__icon {
|
|
1320
|
+
display: inline-flex;
|
|
1321
|
+
}
|
|
1322
|
+
.pl-rail__icon svg {
|
|
1323
|
+
width: 18px;
|
|
1324
|
+
height: 18px;
|
|
1325
|
+
}
|
|
1326
|
+
.pl-rail__label {
|
|
1327
|
+
max-width: 100%;
|
|
1328
|
+
overflow: hidden;
|
|
1329
|
+
font-size: 9px;
|
|
1330
|
+
line-height: 1.1;
|
|
1331
|
+
text-align: center;
|
|
1332
|
+
text-overflow: ellipsis;
|
|
1333
|
+
white-space: nowrap;
|
|
1334
|
+
}
|
|
1335
|
+
.pl-rail__badge {
|
|
1336
|
+
position: absolute;
|
|
1337
|
+
top: 4px;
|
|
1338
|
+
right: 9px;
|
|
1339
|
+
display: inline-flex;
|
|
1340
|
+
align-items: center;
|
|
1341
|
+
justify-content: center;
|
|
1342
|
+
min-width: 15px;
|
|
1343
|
+
height: 15px;
|
|
1344
|
+
padding: 0 4px;
|
|
1345
|
+
font-family: var(--pl-font-mono);
|
|
1346
|
+
font-size: 9px;
|
|
1347
|
+
color: var(--pl-color-fg);
|
|
1348
|
+
background: var(--pl-color-bg-subtle);
|
|
1349
|
+
border: var(--pl-border-width) solid var(--pl-color-border-strong);
|
|
1350
|
+
border-radius: 999px;
|
|
1351
|
+
}
|
|
1352
|
+
.pl-rail__dot {
|
|
1353
|
+
position: absolute;
|
|
1354
|
+
top: 6px;
|
|
1355
|
+
right: 14px;
|
|
1356
|
+
width: 7px;
|
|
1357
|
+
height: 7px;
|
|
1358
|
+
border-radius: 50%;
|
|
1359
|
+
background: var(--pl-color-status-info);
|
|
1360
|
+
animation: pl-dot-pulse var(--pl-motion-status) var(--pl-motion-ease-in-out) infinite;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/* ── MobileNav (bottom bar + drawer list) ────────────────────────────────────── */
|
|
1364
|
+
.pl-mobilenav {
|
|
1365
|
+
display: flex;
|
|
1366
|
+
align-items: stretch;
|
|
1367
|
+
background: var(--pl-color-bg-raised);
|
|
1368
|
+
border-top: var(--pl-border-width) solid var(--pl-color-border);
|
|
1369
|
+
}
|
|
1370
|
+
.pl-mobilenav__tab {
|
|
1371
|
+
flex: 1;
|
|
1372
|
+
display: flex;
|
|
1373
|
+
flex-direction: column;
|
|
1374
|
+
align-items: center;
|
|
1375
|
+
gap: 3px;
|
|
1376
|
+
padding: 8px 2px;
|
|
1377
|
+
background: none;
|
|
1378
|
+
border: none;
|
|
1379
|
+
color: var(--pl-color-fg-muted);
|
|
1380
|
+
font: inherit;
|
|
1381
|
+
font-size: 10px;
|
|
1382
|
+
cursor: pointer;
|
|
1383
|
+
}
|
|
1384
|
+
.pl-mobilenav__tab--active {
|
|
1385
|
+
color: var(--pl-color-fg);
|
|
1386
|
+
}
|
|
1387
|
+
.pl-mobilenav__icon {
|
|
1388
|
+
display: inline-flex;
|
|
1389
|
+
}
|
|
1390
|
+
.pl-mobilenav__icon svg {
|
|
1391
|
+
width: 18px;
|
|
1392
|
+
height: 18px;
|
|
1393
|
+
}
|
|
1394
|
+
.pl-mobilenav__list {
|
|
1395
|
+
display: grid;
|
|
1396
|
+
gap: 2px;
|
|
1397
|
+
}
|
|
1398
|
+
.pl-mobilenav__list-item {
|
|
1399
|
+
display: flex;
|
|
1400
|
+
align-items: center;
|
|
1401
|
+
gap: 10px;
|
|
1402
|
+
width: 100%;
|
|
1403
|
+
padding: 10px 8px;
|
|
1404
|
+
background: none;
|
|
1405
|
+
border: none;
|
|
1406
|
+
border-radius: var(--pl-radius);
|
|
1407
|
+
color: var(--pl-color-fg);
|
|
1408
|
+
font: inherit;
|
|
1409
|
+
font-size: 14px;
|
|
1410
|
+
text-align: left;
|
|
1411
|
+
cursor: pointer;
|
|
1412
|
+
}
|
|
1413
|
+
.pl-mobilenav__list-item:hover {
|
|
1414
|
+
background: var(--pl-color-bg-hover);
|
|
1415
|
+
}
|
|
1416
|
+
.pl-mobilenav__list-item--active {
|
|
1417
|
+
background: var(--pl-color-bg-subtle);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/* ── AppShell (dual-rail + 3-column) ─────────────────────────────────────────── */
|
|
1421
|
+
.pl-appshell {
|
|
1422
|
+
display: flex;
|
|
1423
|
+
align-items: stretch;
|
|
1424
|
+
height: 100%;
|
|
1425
|
+
min-height: 0;
|
|
1426
|
+
background: var(--pl-color-bg);
|
|
1427
|
+
color: var(--pl-color-fg);
|
|
1428
|
+
}
|
|
1429
|
+
.pl-appshell__col {
|
|
1430
|
+
display: flex;
|
|
1431
|
+
flex-direction: column;
|
|
1432
|
+
min-width: 0;
|
|
1433
|
+
min-height: 0;
|
|
1434
|
+
overflow: hidden;
|
|
1435
|
+
}
|
|
1436
|
+
.pl-appshell__col--left {
|
|
1437
|
+
flex: 1 1 auto;
|
|
1438
|
+
}
|
|
1439
|
+
.pl-appshell__col--right {
|
|
1440
|
+
flex: 0 0 auto;
|
|
1441
|
+
border-left: var(--pl-border-width) solid var(--pl-color-border);
|
|
1442
|
+
}
|
|
1443
|
+
.pl-appshell__handle {
|
|
1444
|
+
flex: 0 0 auto;
|
|
1445
|
+
width: 5px;
|
|
1446
|
+
cursor: col-resize;
|
|
1447
|
+
background: transparent;
|
|
1448
|
+
touch-action: none;
|
|
1449
|
+
transition: background var(--pl-motion-fast) var(--pl-motion-ease);
|
|
1450
|
+
}
|
|
1451
|
+
.pl-appshell__handle:hover,
|
|
1452
|
+
.pl-appshell__handle:focus-visible {
|
|
1453
|
+
background: var(--pl-color-border-strong);
|
|
1454
|
+
outline: none;
|
|
1455
|
+
}
|
|
1456
|
+
.pl-appshell--mobile {
|
|
1457
|
+
flex-direction: column;
|
|
1458
|
+
}
|
|
1459
|
+
.pl-appshell__mobile-stage {
|
|
1460
|
+
flex: 1 1 auto;
|
|
1461
|
+
min-height: 0;
|
|
1462
|
+
overflow: auto;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/* ── Skeleton (loading placeholder) ──────────────────────────────────────────── */
|
|
1466
|
+
.pl-skel {
|
|
1467
|
+
display: block;
|
|
1468
|
+
border-radius: var(--pl-radius);
|
|
1469
|
+
background-color: var(--pl-color-bg-subtle);
|
|
1470
|
+
background-image: linear-gradient(
|
|
1471
|
+
90deg,
|
|
1472
|
+
var(--pl-color-bg-subtle) 25%,
|
|
1473
|
+
var(--pl-color-bg-hover) 50%,
|
|
1474
|
+
var(--pl-color-bg-subtle) 75%
|
|
1475
|
+
);
|
|
1476
|
+
background-size: 200% 100%;
|
|
1477
|
+
animation: pl-skel-shimmer 1.4s var(--pl-motion-ease-in-out) infinite;
|
|
1478
|
+
}
|
|
1479
|
+
@keyframes pl-skel-shimmer {
|
|
1480
|
+
from {
|
|
1481
|
+
background-position: 200% 0;
|
|
1482
|
+
}
|
|
1483
|
+
to {
|
|
1484
|
+
background-position: -200% 0;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
.pl-skel-lines {
|
|
1488
|
+
display: grid;
|
|
1489
|
+
gap: 8px;
|
|
1490
|
+
}
|
|
1491
|
+
.pl-skel-group {
|
|
1492
|
+
display: grid;
|
|
1493
|
+
gap: var(--pl-space-3);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1242
1496
|
/* Respect reduced-motion for every animation this package introduces. */
|
|
1243
1497
|
@media (prefers-reduced-motion: reduce) {
|
|
1244
1498
|
.pl-drawer,
|
|
1245
1499
|
.pl-toast,
|
|
1246
1500
|
.pl-dot--pulse,
|
|
1501
|
+
.pl-rail__dot,
|
|
1247
1502
|
.pl-spinner {
|
|
1248
1503
|
animation: none;
|
|
1249
1504
|
}
|
|
1505
|
+
/* Skeletons go static (solid fill, no shimmer). */
|
|
1506
|
+
.pl-skel {
|
|
1507
|
+
animation: none;
|
|
1508
|
+
background-image: none;
|
|
1509
|
+
}
|
|
1250
1510
|
}
|