@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protolabsai/ui",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -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
+ };
@@ -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" reads as a stronger border, not a fill (brand restraint). */
36
- variant?: "default" | "primary";
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 <button className={cx("pl-btn", variant === "primary" && "pl-btn--primary", className)} {...rest} />;
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
- export function ScrollArea({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
750
- return <div className={cx("pl-scroll", className)} {...rest} />;
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
  }