@protolabsai/ui 0.6.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.6.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
+ };
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,
@@ -1022,3 +1025,305 @@ export function Skeleton({
1022
1025
  export function SkeletonGroup({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
1023
1026
  return <div className={cx("pl-skel-group", className)} {...rest} />;
1024
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
@@ -1275,6 +1275,193 @@ a.pl-row:hover {
1275
1275
  color: var(--pl-color-fg-muted);
1276
1276
  }
1277
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
+
1278
1465
  /* ── Skeleton (loading placeholder) ──────────────────────────────────────────── */
1279
1466
  .pl-skel {
1280
1467
  display: block;
@@ -1311,6 +1498,7 @@ a.pl-row:hover {
1311
1498
  .pl-drawer,
1312
1499
  .pl-toast,
1313
1500
  .pl-dot--pulse,
1501
+ .pl-rail__dot,
1314
1502
  .pl-spinner {
1315
1503
  animation: none;
1316
1504
  }