@protolabsai/ui 0.7.0 → 0.8.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.7.0",
3
+ "version": "0.8.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -12,7 +12,15 @@
12
12
  "*.css"
13
13
  ],
14
14
  "exports": {
15
- ".": "./src/index.tsx",
15
+ "./primitives": "./src/primitives.tsx",
16
+ "./layout": "./src/layout.tsx",
17
+ "./marketing": "./src/marketing.tsx",
18
+ "./navigation": "./src/navigation.tsx",
19
+ "./forms": "./src/forms.tsx",
20
+ "./overlays": "./src/overlays.tsx",
21
+ "./data": "./src/data.tsx",
22
+ "./menu": "./src/menu.tsx",
23
+ "./app-shell": "./src/app-shell.tsx",
16
24
  "./styles.css": "./src/styles.css"
17
25
  },
18
26
  "files": [
@@ -1,7 +1,9 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
- import { AppShell, Button, MobileNav, PanelHeader, SurfaceRail } from "./index";
4
- import type { MobileItem, RailItem } from "./index";
3
+ import { Button } from "./primitives";
4
+ import { PanelHeader } from "./navigation";
5
+ import { AppShell, MobileNav, SurfaceRail } from "./app-shell";
6
+ import type { MobileItem, RailItem } from "./app-shell";
5
7
 
6
8
  const meta: Meta = { title: "Components/AppShell" };
7
9
  export default meta;
@@ -1,8 +1,9 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
- import { Badge, Button, PanelHeader, Tabs } from "./index";
3
+ import { Badge, Button } from "./primitives";
4
+ import { PanelHeader, Tabs } from "./navigation";
4
5
 
5
- const meta: Meta = { title: "Components/AppShell" };
6
+ const meta: Meta = { title: "Components/Navigation" };
6
7
  export default meta;
7
8
  type Story = StoryObj;
8
9
 
@@ -1,8 +1,8 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Badge, type Status } from "./index";
2
+ import { Badge, type Status } from "./primitives";
3
3
 
4
4
  const meta: Meta<typeof Badge> = {
5
- title: "Components/Badge",
5
+ title: "Components/Primitives/Badge",
6
6
  component: Badge,
7
7
  args: { children: "released" },
8
8
  argTypes: { status: { control: "inline-radio", options: ["neutral", "success", "warning", "error", "info"] } },
@@ -1,7 +1,9 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Container, Empty, PostItem, PostList, Prose } from "./index";
2
+ import { Empty } from "./primitives";
3
+ import { Container } from "./layout";
4
+ import { PostItem, PostList, Prose } from "./marketing";
3
5
 
4
- const meta: Meta = { title: "Components/Blog" };
6
+ const meta: Meta = { title: "Components/Marketing/Blog" };
5
7
  export default meta;
6
8
  type Story = StoryObj;
7
9
 
@@ -1,8 +1,8 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Button } from "./index";
2
+ import { Button } from "./primitives";
3
3
 
4
4
  const meta: Meta<typeof Button> = {
5
- title: "Components/Button",
5
+ title: "Components/Primitives/Button",
6
6
  component: Button,
7
7
  args: { children: "Breakdowns" },
8
8
  argTypes: { variant: { control: "inline-radio", options: ["default", "primary", "ghost", "danger"] } },
@@ -1,7 +1,9 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Button, Container, Eyebrow, GradientText, Hero, HeroActions, Heading, Lead, Section, SectionIntro } from "./index";
2
+ import { Button, Eyebrow } from "./primitives";
3
+ import { Container, Section } from "./layout";
4
+ import { GradientText, Hero, HeroActions, Heading, Lead, SectionIntro } from "./marketing";
3
5
 
4
- const meta: Meta = { title: "Components/Content" };
6
+ const meta: Meta = { title: "Components/Marketing/Content" };
5
7
  export default meta;
6
8
  type Story = StoryObj;
7
9
 
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
- import { Badge, ScrollArea, Spinner, StatusDot, TBody, THead, Table, Td, Th, Tr } from "./index";
3
+ import { Badge } from "./primitives";
4
+ import { ScrollArea, Spinner, StatusDot, TBody, THead, Table, Td, Th, Tr } from "./data";
4
5
 
5
6
  const meta: Meta = { title: "Components/Data" };
6
7
  export default meta;
@@ -1,6 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
- import { Checkbox, Field, Input, Select, Switch, Textarea } from "./index";
3
+ import { Checkbox, Field, Input, Select, Switch, Textarea } from "./forms";
4
4
 
5
5
  const meta: Meta = { title: "Components/Forms" };
6
6
  export default meta;
@@ -1,17 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import {
3
- Callout,
4
- Container,
5
- Divider,
6
- Eyebrow,
7
- GradientText,
8
- Heading,
9
- Hero,
10
- Lead,
11
- Row,
12
- Section,
13
- SectionIntro,
14
- } from "./index";
2
+ import { Callout, Divider, Eyebrow } from "./primitives";
3
+ import { Container, Row, Section } from "./layout";
4
+ import { GradientText, Heading, Hero, Lead, SectionIntro } from "./marketing";
15
5
 
16
6
  const meta: Meta = {
17
7
  title: "Introduction",
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useRef } from "react";
3
- import { Button, Menu, MenuHandle, MenuItem, MenuLabel, MenuSeparator, MenuSub } from "./index";
3
+ import { Button } from "./primitives";
4
+ import { Menu, MenuHandle, MenuItem, MenuLabel, MenuSeparator, MenuSub } from "./menu";
4
5
 
5
6
  const meta: Meta = { title: "Components/Menu" };
6
7
  export default meta;
@@ -1,15 +1,8 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
- import {
4
- Button,
5
- ConfirmDialog,
6
- Dialog,
7
- Drawer,
8
- Field,
9
- Tooltip,
10
- ToastProvider,
11
- useToast,
12
- } from "./index";
3
+ import { Button } from "./primitives";
4
+ import { Field } from "./forms";
5
+ import { ConfirmDialog, Dialog, Drawer, Tooltip, ToastProvider, useToast } from "./overlays";
13
6
 
14
7
  const meta: Meta = { title: "Components/Overlays" };
15
8
  export default meta;
@@ -1,7 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Callout, Divider, Kbd, TextLink } from "./index";
2
+ import { Callout, Divider, Kbd, TextLink } from "./primitives";
3
3
 
4
- const meta: Meta = { title: "Components/Primitives" };
4
+ const meta: Meta = { title: "Components/Primitives/Atoms" };
5
5
  export default meta;
6
6
  type Story = StoryObj;
7
7
 
@@ -1,7 +1,8 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Check, Checks, Deliverable, Deliverables, Heading, Section, Step, Steps } from "./index";
2
+ import { Section } from "./layout";
3
+ import { Check, Checks, Deliverable, Deliverables, Heading, Step, Steps } from "./marketing";
3
4
 
4
- const meta: Meta = { title: "Components/Process" };
5
+ const meta: Meta = { title: "Components/Marketing/Process" };
5
6
  export default meta;
6
7
  type Story = StoryObj;
7
8
 
@@ -1,7 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Row, Stats, Stat } from "./index";
2
+ import { Row, Stats, Stat } from "./layout";
3
3
 
4
- const meta: Meta<typeof Row> = { title: "Components/Row", component: Row };
4
+ const meta: Meta<typeof Row> = { title: "Components/Layout/Row", component: Row };
5
5
  export default meta;
6
6
  type Story = StoryObj<typeof Row>;
7
7
 
@@ -1,7 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Skeleton, SkeletonGroup } from "./index";
2
+ import { Skeleton, SkeletonGroup } from "./data";
3
3
 
4
- const meta: Meta = { title: "Components/Skeleton" };
4
+ const meta: Meta = { title: "Components/Data/Skeleton" };
5
5
  export default meta;
6
6
  type Story = StoryObj;
7
7
 
@@ -1,7 +1,8 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Card, Eyebrow, Stat } from "./index";
2
+ import { Card, Eyebrow } from "./primitives";
3
+ import { Stat } from "./layout";
3
4
 
4
- const meta: Meta = { title: "Components/Surface" };
5
+ const meta: Meta = { title: "Components/Primitives/Surface" };
5
6
  export default meta;
6
7
  type Story = StoryObj;
7
8
 
@@ -0,0 +1,309 @@
1
+ // The operator-console shell, converged from protoAgent's production ADR 0035
2
+ // dual-rail layout. All three are dumb + props-driven; persistence (rail order,
3
+ // widths, active surfaces) stays app-side — AppShell is controlled.
4
+ import type {
5
+ KeyboardEvent as ReactKeyboardEvent,
6
+ MouseEvent as ReactMouseEvent,
7
+ PointerEvent as ReactPointerEvent,
8
+ ReactNode,
9
+ } from "react";
10
+ import { useCallback, useEffect, useRef, useState } from "react";
11
+ import { cx } from "./internal";
12
+ import { Drawer } from "./overlays";
13
+
14
+ export type RailItem = {
15
+ id: string;
16
+ label: string;
17
+ icon: ReactNode;
18
+ /** Count badge (caps at "9+"). Mutually exclusive with `dot`. */
19
+ badge?: number;
20
+ /** Pulsing indicator, no count (e.g. a background stream). */
21
+ dot?: boolean;
22
+ };
23
+
24
+ /** Vertical icon rail — both the left and right rails render through this.
25
+ * `onContextMenu` is the integration point for the DS `Menu` (right-click →
26
+ * host calls menuRef.open({x,y})); the menu's registry/keying stays app-side. */
27
+ export function SurfaceRail({
28
+ side,
29
+ ariaLabel,
30
+ items,
31
+ activeId,
32
+ onSelect,
33
+ onContextMenu,
34
+ }: {
35
+ side: "left" | "right";
36
+ ariaLabel: string;
37
+ items: RailItem[];
38
+ activeId: string;
39
+ onSelect: (id: string) => void;
40
+ onContextMenu?: (e: ReactMouseEvent, id: string) => void;
41
+ }) {
42
+ return (
43
+ <aside className={cx("pl-rail", side === "right" && "pl-rail--right")} aria-label={ariaLabel}>
44
+ {items.map((it) => (
45
+ <button
46
+ key={it.id}
47
+ type="button"
48
+ className={cx("pl-rail__btn", it.id === activeId && "pl-rail__btn--active")}
49
+ title={it.label}
50
+ aria-label={it.label}
51
+ aria-current={it.id === activeId ? "page" : undefined}
52
+ onClick={() => onSelect(it.id)}
53
+ onContextMenu={onContextMenu ? (e) => onContextMenu(e, it.id) : undefined}
54
+ >
55
+ <span className="pl-rail__icon" aria-hidden>
56
+ {it.icon}
57
+ </span>
58
+ <span className="pl-rail__label">{it.label}</span>
59
+ {it.badge ? (
60
+ <span className="pl-rail__badge">{it.badge > 9 ? "9+" : it.badge}</span>
61
+ ) : it.dot ? (
62
+ <span className="pl-rail__dot" aria-label="active" />
63
+ ) : null}
64
+ </button>
65
+ ))}
66
+ </aside>
67
+ );
68
+ }
69
+
70
+ export type MobileItem = { id: string; label: string; icon: ReactNode };
71
+
72
+ /** Mobile shell (<768px): a bottom quick-bar (first 5 pinned surfaces) + a
73
+ * "More" button that opens the full surface list in a DS `Drawer`. */
74
+ export function MobileNav({
75
+ items,
76
+ activeId,
77
+ onSelect,
78
+ quickBarIds,
79
+ }: {
80
+ items: MobileItem[];
81
+ activeId: string;
82
+ onSelect: (id: string) => void;
83
+ /** Surfaces pinned to the bottom bar (first 5 used). */
84
+ quickBarIds: string[];
85
+ }) {
86
+ const [open, setOpen] = useState(false);
87
+ const byId = new Map(items.map((i) => [i.id, i] as const));
88
+ const quick = quickBarIds
89
+ .map((id) => byId.get(id))
90
+ .filter((i): i is MobileItem => Boolean(i))
91
+ .slice(0, 5);
92
+ const pick = (id: string) => {
93
+ onSelect(id);
94
+ setOpen(false);
95
+ };
96
+ return (
97
+ <>
98
+ <nav className="pl-mobilenav" aria-label="Quick surfaces">
99
+ {quick.map((it) => (
100
+ <button
101
+ key={it.id}
102
+ type="button"
103
+ className={cx("pl-mobilenav__tab", it.id === activeId && "pl-mobilenav__tab--active")}
104
+ onClick={() => pick(it.id)}
105
+ >
106
+ <span className="pl-mobilenav__icon" aria-hidden>
107
+ {it.icon}
108
+ </span>
109
+ <span>{it.label}</span>
110
+ </button>
111
+ ))}
112
+ <button type="button" className="pl-mobilenav__tab" onClick={() => setOpen(true)} aria-label="All surfaces">
113
+ <span className="pl-mobilenav__icon" aria-hidden>
114
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
115
+ <path d="M3 6h18M3 12h18M3 18h18" />
116
+ </svg>
117
+ </span>
118
+ <span>More</span>
119
+ </button>
120
+ </nav>
121
+ <Drawer open={open} onClose={() => setOpen(false)} side="right" title="Surfaces" width={280}>
122
+ <div className="pl-mobilenav__list">
123
+ {items.map((it) => (
124
+ <button
125
+ key={it.id}
126
+ type="button"
127
+ className={cx("pl-mobilenav__list-item", it.id === activeId && "pl-mobilenav__list-item--active")}
128
+ onClick={() => pick(it.id)}
129
+ >
130
+ <span className="pl-mobilenav__icon" aria-hidden>
131
+ {it.icon}
132
+ </span>
133
+ <span>{it.label}</span>
134
+ </button>
135
+ ))}
136
+ </div>
137
+ </Drawer>
138
+ </>
139
+ );
140
+ }
141
+
142
+ /** True below `breakpoint` px (client-only; false on first paint). */
143
+ function useIsMobile(breakpoint: number) {
144
+ const [mobile, setMobile] = useState(false);
145
+ useEffect(() => {
146
+ if (typeof window === "undefined" || !window.matchMedia) return;
147
+ const mq = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
148
+ const update = () => setMobile(mq.matches);
149
+ update();
150
+ mq.addEventListener("change", update);
151
+ return () => mq.removeEventListener("change", update);
152
+ }, [breakpoint]);
153
+ return mobile;
154
+ }
155
+
156
+ export type AppShellProps = {
157
+ leftItems: RailItem[];
158
+ rightItems: RailItem[];
159
+ activeLeft: string;
160
+ activeRight: string;
161
+ onSelect: (side: "left" | "right", id: string) => void;
162
+ /** Right-click on a rail icon — wire to a DS `Menu` for move/reorder etc. */
163
+ onRailContextMenu?: (side: "left" | "right", e: ReactMouseEvent, id: string) => void;
164
+ leftContent: ReactNode;
165
+ rightContent: ReactNode;
166
+ /** Controlled right-column width (px). */
167
+ rightWidth: number;
168
+ onRightWidthChange: (width: number) => void;
169
+ rightCollapsed?: boolean;
170
+ onCollapse?: (collapsed: boolean) => void;
171
+ minRightWidth?: number;
172
+ maxRightWidth?: number;
173
+ /** Mobile (<breakpoint) config. Omit to disable the mobile shell. */
174
+ mobileItems?: MobileItem[];
175
+ mobileActiveId?: string;
176
+ onMobileSelect?: (id: string) => void;
177
+ quickBarIds?: string[];
178
+ mobileBreakpoint?: number;
179
+ className?: string;
180
+ };
181
+
182
+ /** The full dual-rail operator shell:
183
+ * `[left rail][left column][resize handle][right column][right rail]`,
184
+ * collapsing to `MobileNav` below `mobileBreakpoint`. Controlled — the host
185
+ * owns the surface registry and persists rail order / widths / active state. */
186
+ export function AppShell({
187
+ leftItems,
188
+ rightItems,
189
+ activeLeft,
190
+ activeRight,
191
+ onSelect,
192
+ onRailContextMenu,
193
+ leftContent,
194
+ rightContent,
195
+ rightWidth,
196
+ onRightWidthChange,
197
+ rightCollapsed = false,
198
+ onCollapse,
199
+ minRightWidth = 280,
200
+ maxRightWidth = 720,
201
+ mobileItems,
202
+ mobileActiveId,
203
+ onMobileSelect,
204
+ quickBarIds,
205
+ mobileBreakpoint = 768,
206
+ className,
207
+ }: AppShellProps) {
208
+ const isMobile = useIsMobile(mobileBreakpoint);
209
+ const drag = useRef<{ startX: number; startW: number } | null>(null);
210
+ const clamp = useCallback(
211
+ (w: number) => Math.min(maxRightWidth, Math.max(minRightWidth, w)),
212
+ [minRightWidth, maxRightWidth],
213
+ );
214
+
215
+ const onPointerDown = useCallback(
216
+ (e: ReactPointerEvent<HTMLDivElement>) => {
217
+ e.preventDefault();
218
+ drag.current = { startX: e.clientX, startW: rightWidth };
219
+ e.currentTarget.setPointerCapture(e.pointerId);
220
+ },
221
+ [rightWidth],
222
+ );
223
+ const onPointerMove = useCallback(
224
+ (e: ReactPointerEvent<HTMLDivElement>) => {
225
+ if (!drag.current) return;
226
+ onRightWidthChange(clamp(drag.current.startW + (drag.current.startX - e.clientX)));
227
+ },
228
+ [clamp, onRightWidthChange],
229
+ );
230
+ const onPointerUp = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
231
+ drag.current = null;
232
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
233
+ }, []);
234
+ const onKeyDown = useCallback(
235
+ (e: ReactKeyboardEvent<HTMLDivElement>) => {
236
+ const step = e.shiftKey ? 48 : 16;
237
+ if (e.key === "ArrowLeft") {
238
+ e.preventDefault();
239
+ onRightWidthChange(clamp(rightWidth + step));
240
+ } else if (e.key === "ArrowRight") {
241
+ e.preventDefault();
242
+ onRightWidthChange(clamp(rightWidth - step));
243
+ }
244
+ },
245
+ [rightWidth, clamp, onRightWidthChange],
246
+ );
247
+
248
+ if (isMobile && mobileItems && onMobileSelect && quickBarIds) {
249
+ return (
250
+ <div className={cx("pl-appshell", "pl-appshell--mobile", className)}>
251
+ <div className="pl-appshell__mobile-stage">{leftContent}</div>
252
+ <MobileNav
253
+ items={mobileItems}
254
+ activeId={mobileActiveId ?? activeLeft}
255
+ onSelect={onMobileSelect}
256
+ quickBarIds={quickBarIds}
257
+ />
258
+ </div>
259
+ );
260
+ }
261
+
262
+ const showRight = !rightCollapsed && rightItems.length > 0;
263
+ return (
264
+ <div className={cx("pl-appshell", className)}>
265
+ <SurfaceRail
266
+ side="left"
267
+ ariaLabel="Left surfaces"
268
+ items={leftItems}
269
+ activeId={activeLeft}
270
+ onSelect={(id) => onSelect("left", id)}
271
+ onContextMenu={onRailContextMenu ? (e, id) => onRailContextMenu("left", e, id) : undefined}
272
+ />
273
+ <main className="pl-appshell__col pl-appshell__col--left">{leftContent}</main>
274
+ {showRight && (
275
+ <div
276
+ className="pl-appshell__handle"
277
+ role="separator"
278
+ aria-orientation="vertical"
279
+ aria-label="Resize right panel"
280
+ aria-valuenow={rightWidth}
281
+ aria-valuemin={minRightWidth}
282
+ aria-valuemax={maxRightWidth}
283
+ tabIndex={0}
284
+ onPointerDown={onPointerDown}
285
+ onPointerMove={onPointerMove}
286
+ onPointerUp={onPointerUp}
287
+ onKeyDown={onKeyDown}
288
+ onDoubleClick={() => onCollapse?.(true)}
289
+ />
290
+ )}
291
+ {showRight && (
292
+ <aside
293
+ className="pl-appshell__col pl-appshell__col--right"
294
+ style={{ flex: `0 0 ${rightWidth}px`, width: rightWidth }}
295
+ >
296
+ {rightContent}
297
+ </aside>
298
+ )}
299
+ <SurfaceRail
300
+ side="right"
301
+ ariaLabel="Right surfaces"
302
+ items={rightItems}
303
+ activeId={activeRight}
304
+ onSelect={(id) => onSelect("right", id)}
305
+ onContextMenu={onRailContextMenu ? (e, id) => onRailContextMenu("right", e, id) : undefined}
306
+ />
307
+ </div>
308
+ );
309
+ }
package/src/data.tsx ADDED
@@ -0,0 +1,126 @@
1
+ import type {
2
+ HTMLAttributes,
3
+ ReactNode,
4
+ TableHTMLAttributes,
5
+ TdHTMLAttributes,
6
+ ThHTMLAttributes,
7
+ } from "react";
8
+ import { cx } from "./internal";
9
+ import type { Status } from "./internal";
10
+
11
+ /** Dense data table on the 4px grid. Compose with THead/TBody/Tr/Th/Td. */
12
+ export function Table({ className, ...rest }: TableHTMLAttributes<HTMLTableElement>) {
13
+ return <table className={cx("pl-table", className)} {...rest} />;
14
+ }
15
+ export function THead(props: HTMLAttributes<HTMLTableSectionElement>) {
16
+ return <thead {...props} />;
17
+ }
18
+ export function TBody(props: HTMLAttributes<HTMLTableSectionElement>) {
19
+ return <tbody {...props} />;
20
+ }
21
+ /** Table row. `selected` highlights; an `onClick` makes it hover-interactive. */
22
+ export function Tr({
23
+ selected,
24
+ className,
25
+ ...rest
26
+ }: HTMLAttributes<HTMLTableRowElement> & { selected?: boolean }) {
27
+ return (
28
+ <tr
29
+ className={cx(selected && "pl-tr--selected", rest.onClick && "pl-tr--interactive", className)}
30
+ {...rest}
31
+ />
32
+ );
33
+ }
34
+ export function Th({ className, ...rest }: ThHTMLAttributes<HTMLTableCellElement>) {
35
+ return <th className={className} {...rest} />;
36
+ }
37
+ export function Td({ className, ...rest }: TdHTMLAttributes<HTMLTableCellElement>) {
38
+ return <td className={className} {...rest} />;
39
+ }
40
+
41
+ /** Live/health indicator. `pulse` breathes on the 2s status cadence. */
42
+ export function StatusDot({
43
+ status = "neutral",
44
+ pulse,
45
+ label,
46
+ }: {
47
+ status?: Status;
48
+ pulse?: boolean;
49
+ label?: ReactNode;
50
+ }) {
51
+ const dot = (
52
+ <span
53
+ className={cx("pl-dot", status !== "neutral" && `pl-dot--${status}`, pulse && "pl-dot--pulse")}
54
+ aria-hidden
55
+ />
56
+ );
57
+ if (label == null) return dot;
58
+ return (
59
+ <span className="pl-dot-row">
60
+ {dot}
61
+ <span className="pl-dot-row__label">{label}</span>
62
+ </span>
63
+ );
64
+ }
65
+
66
+ /** Indeterminate spinner (1s linear, brand-restrained). */
67
+ export function Spinner({ size = 16, className }: { size?: number; className?: string }) {
68
+ return (
69
+ <span
70
+ className={cx("pl-spinner", className)}
71
+ style={{ width: size, height: size }}
72
+ role="status"
73
+ aria-label="Loading"
74
+ />
75
+ );
76
+ }
77
+
78
+ /** Scroll container with brand-styled thin scrollbars. Carries `min-height:0`
79
+ * (so it scrolls inside flex/grid parents) + overscroll containment. Pass
80
+ * `ariaLabel` to make it a keyboard-focusable, labelled scroll region. */
81
+ export function ScrollArea({
82
+ ariaLabel,
83
+ className,
84
+ ...rest
85
+ }: HTMLAttributes<HTMLDivElement> & { ariaLabel?: string }) {
86
+ const a11y = ariaLabel != null ? { role: "region", "aria-label": ariaLabel, tabIndex: 0 } : {};
87
+ return <div className={cx("pl-scroll", className)} {...a11y} {...rest} />;
88
+ }
89
+
90
+ /** Shimmering content-shaped loading placeholder. `lines>1` stacks text bars
91
+ * (last one short). Token-driven; static fill under reduced-motion. */
92
+ export function Skeleton({
93
+ width,
94
+ height = 14,
95
+ lines,
96
+ className,
97
+ style,
98
+ ...rest
99
+ }: HTMLAttributes<HTMLDivElement> & {
100
+ width?: number | string;
101
+ height?: number | string;
102
+ /** Stack N text-line bars instead of a single bar. */
103
+ lines?: number;
104
+ }) {
105
+ if (lines != null && lines > 1) {
106
+ return (
107
+ <div className={cx("pl-skel-lines", className)} style={style} {...rest}>
108
+ {Array.from({ length: lines }, (_, i) => (
109
+ <div
110
+ key={i}
111
+ className="pl-skel"
112
+ style={{ height, width: i === lines - 1 ? "65%" : (width ?? "100%") }}
113
+ />
114
+ ))}
115
+ </div>
116
+ );
117
+ }
118
+ return (
119
+ <div className={cx("pl-skel", className)} style={{ width: width ?? "100%", height, ...style }} {...rest} />
120
+ );
121
+ }
122
+
123
+ /** Optional wrapper to group related skeletons (shared layout gap). */
124
+ export function SkeletonGroup({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
125
+ return <div className={cx("pl-skel-group", className)} {...rest} />;
126
+ }