@protolabsai/ui 0.4.0 → 0.6.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.4.0",
3
+ "version": "0.6.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -19,6 +19,7 @@
19
19
  "src"
20
20
  ],
21
21
  "dependencies": {
22
+ "@radix-ui/react-dropdown-menu": "^2.1.17",
22
23
  "@types/react": "^19.0.0",
23
24
  "@types/react-dom": "^19.0.0",
24
25
  "@protolabsai/design": "0.4.0"
@@ -0,0 +1,61 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { Badge, Button, PanelHeader, Tabs } from "./index";
4
+
5
+ const meta: Meta = { title: "Components/AppShell" };
6
+ export default meta;
7
+ type Story = StoryObj;
8
+
9
+ const Glyph = () => (
10
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
11
+ <rect x="2.5" y="2.5" width="11" height="11" rx="2" />
12
+ </svg>
13
+ );
14
+
15
+ export const Panel: Story = {
16
+ render: () => (
17
+ <div style={{ display: "grid", gap: 20, maxWidth: 560 }}>
18
+ <div style={{ border: "1px solid var(--pl-color-border)", borderRadius: 4 }}>
19
+ <PanelHeader
20
+ kicker="agent · streaming-router"
21
+ title="Activity"
22
+ actions={
23
+ <>
24
+ <Button>Filter</Button>
25
+ <Button variant="primary">New run</Button>
26
+ </>
27
+ }
28
+ />
29
+ <div style={{ padding: 16, color: "var(--pl-color-fg-muted)", fontSize: 13 }}>panel body…</div>
30
+ </div>
31
+
32
+ <div style={{ border: "1px solid var(--pl-color-border)", borderRadius: 4 }}>
33
+ <PanelHeader compact title="Inbox" actions={<Badge status="warning">3 unread</Badge>} />
34
+ <div style={{ padding: 12, color: "var(--pl-color-fg-muted)", fontSize: 13 }}>compact, nested panel…</div>
35
+ </div>
36
+ </div>
37
+ ),
38
+ };
39
+
40
+ export const TabsWithSlots: Story = {
41
+ render: () => {
42
+ function Demo() {
43
+ const [active, setActive] = useState("activity");
44
+ return (
45
+ <Tabs
46
+ active={active}
47
+ onSelect={setActive}
48
+ items={[
49
+ { id: "chat", label: "Chat", icon: <Glyph /> },
50
+ { id: "activity", label: "Activity", icon: <Glyph />, badge: 12 },
51
+ { id: "knowledge", label: "Knowledge", icon: <Glyph /> },
52
+ { id: "plugins", label: "Plugins", icon: <Glyph />, badge: "new" },
53
+ { id: "settings", label: "Settings", icon: <Glyph /> },
54
+ { id: "billing", label: "Billing", icon: <Glyph />, locked: true, disabled: true },
55
+ ]}
56
+ />
57
+ );
58
+ }
59
+ return <Demo />;
60
+ },
61
+ };
@@ -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,76 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useRef } from "react";
3
+ import { Button, Menu, MenuHandle, MenuItem, MenuLabel, MenuSeparator, MenuSub } from "./index";
4
+
5
+ const meta: Meta = { title: "Components/Menu" };
6
+ export default meta;
7
+ type Story = StoryObj;
8
+
9
+ // Tiny inline icon so stories stay dependency-free.
10
+ const Dot = () => (
11
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
12
+ <circle cx="8" cy="8" r="3" />
13
+ </svg>
14
+ );
15
+
16
+ const Items = () => (
17
+ <>
18
+ <MenuLabel>Rail surface</MenuLabel>
19
+ <MenuItem icon={<Dot />}>Move up</MenuItem>
20
+ <MenuItem icon={<Dot />}>Move down</MenuItem>
21
+ <MenuSub label="Move to other rail" icon={<Dot />}>
22
+ <MenuItem>Left rail</MenuItem>
23
+ <MenuItem>Right rail</MenuItem>
24
+ </MenuSub>
25
+ <MenuSeparator />
26
+ <MenuItem disabled icon={<Dot />}>
27
+ Pin (coming soon)
28
+ </MenuItem>
29
+ <MenuItem destructive icon={<Dot />}>
30
+ Remove surface
31
+ </MenuItem>
32
+ </>
33
+ );
34
+
35
+ export const Trigger: Story = {
36
+ render: () => (
37
+ <Menu trigger={<Button>Surface actions ▾</Button>}>
38
+ <Items />
39
+ </Menu>
40
+ ),
41
+ };
42
+
43
+ export const RightClickAtCoords: Story = {
44
+ render: () => {
45
+ function Demo() {
46
+ const menu = useRef<MenuHandle>(null);
47
+ return (
48
+ <>
49
+ <div
50
+ onContextMenu={(e) => {
51
+ e.preventDefault();
52
+ menu.current?.open({ x: e.clientX, y: e.clientY });
53
+ }}
54
+ style={{
55
+ height: 180,
56
+ display: "grid",
57
+ placeItems: "center",
58
+ border: "1px dashed var(--pl-color-border-strong)",
59
+ borderRadius: 4,
60
+ color: "var(--pl-color-fg-muted)",
61
+ fontFamily: "var(--pl-font-mono)",
62
+ fontSize: 13,
63
+ userSelect: "none",
64
+ }}
65
+ >
66
+ right-click anywhere in this box
67
+ </div>
68
+ <Menu ref={menu}>
69
+ <Items />
70
+ </Menu>
71
+ </>
72
+ );
73
+ }
74
+ return <Demo />;
75
+ },
76
+ };
@@ -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
@@ -15,17 +15,43 @@ import type {
15
15
  TextareaHTMLAttributes,
16
16
  ThHTMLAttributes,
17
17
  } from "react";
18
- import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
18
+ import {
19
+ createContext,
20
+ forwardRef,
21
+ useCallback,
22
+ useContext,
23
+ useEffect,
24
+ useId,
25
+ useImperativeHandle,
26
+ useRef,
27
+ useState,
28
+ } from "react";
29
+ import * as RDropdown from "@radix-ui/react-dropdown-menu";
19
30
  import "./styles.css";
20
31
 
21
32
  const cx = (...parts: Array<string | false | undefined>) => parts.filter(Boolean).join(" ");
22
33
 
23
34
  export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
24
- /** "primary" reads as a stronger border, not a fill (brand restraint). */
25
- variant?: "default" | "primary";
35
+ /** "primary"/"danger" read as a stronger border, not a fill (brand restraint);
36
+ * "ghost" is transparent until hover. */
37
+ variant?: "default" | "primary" | "ghost" | "danger";
38
+ size?: "sm" | "md";
39
+ /** Icon-only: square, centered glyph. Pass `aria-label` for a11y. */
40
+ icon?: boolean;
26
41
  };
27
- export function Button({ variant = "default", className, ...rest }: ButtonProps) {
28
- return <button className={cx("pl-btn", variant === "primary" && "pl-btn--primary", className)} {...rest} />;
42
+ export function Button({ variant = "default", size = "md", icon, className, ...rest }: ButtonProps) {
43
+ return (
44
+ <button
45
+ className={cx(
46
+ "pl-btn",
47
+ variant !== "default" && `pl-btn--${variant}`,
48
+ size === "sm" && "pl-btn--sm",
49
+ icon && "pl-btn--icon",
50
+ className,
51
+ )}
52
+ {...rest}
53
+ />
54
+ );
29
55
  }
30
56
 
31
57
  export type Status = "neutral" | "success" | "warning" | "error" | "info";
@@ -240,9 +266,19 @@ export function TextLink({
240
266
 
241
267
  // ── App primitives (cockpit + future studio apps) ────────────────────────────
242
268
 
243
- export type TabItem = { id: string; label: ReactNode; disabled?: boolean; locked?: boolean };
269
+ export type TabItem = {
270
+ id: string;
271
+ label: ReactNode;
272
+ /** Leading icon (e.g. a Lucide glyph). */
273
+ icon?: ReactNode;
274
+ /** Trailing badge / count (e.g. unread inbox count). */
275
+ badge?: ReactNode;
276
+ disabled?: boolean;
277
+ locked?: boolean;
278
+ };
244
279
 
245
- /** A horizontal tab strip with disabled/locked support (gated workflows). */
280
+ /** A horizontal tab strip with optional icon/badge slots + disabled/locked
281
+ * support (gated workflows). */
246
282
  export function Tabs({ items, active, onSelect }: { items: TabItem[]; active: string; onSelect: (id: string) => void }) {
247
283
  return (
248
284
  <div className="pl-tabs" role="tablist">
@@ -256,7 +292,13 @@ export function Tabs({ items, active, onSelect }: { items: TabItem[]; active: st
256
292
  disabled={t.disabled}
257
293
  onClick={() => onSelect(t.id)}
258
294
  >
259
- {t.label}
295
+ {t.icon != null && (
296
+ <span className="pl-tab__icon" aria-hidden>
297
+ {t.icon}
298
+ </span>
299
+ )}
300
+ <span className="pl-tab__label">{t.label}</span>
301
+ {t.badge != null && <span className="pl-tab__badge">{t.badge}</span>}
260
302
  {t.locked ? (
261
303
  <span className="pl-tab__lock" aria-hidden>
262
304
  🔒
@@ -322,6 +364,33 @@ export function Field({
322
364
  );
323
365
  }
324
366
 
367
+ /** Dense operator-console panel header — title + optional kicker eyebrow +
368
+ * right-aligned actions slot. `compact` tightens it for nested/secondary
369
+ * panels. The most-used console composite. */
370
+ export function PanelHeader({
371
+ title,
372
+ kicker,
373
+ actions,
374
+ compact,
375
+ className,
376
+ }: {
377
+ title: ReactNode;
378
+ kicker?: ReactNode;
379
+ actions?: ReactNode;
380
+ compact?: boolean;
381
+ className?: string;
382
+ }) {
383
+ return (
384
+ <div className={cx("pl-panel-header", compact && "pl-panel-header--compact", className)}>
385
+ <div className="pl-panel-header__titles">
386
+ {kicker != null && <div className="pl-panel-header__kicker">{kicker}</div>}
387
+ <h2 className="pl-panel-header__title">{title}</h2>
388
+ </div>
389
+ {actions != null && <div className="pl-panel-header__actions">{actions}</div>}
390
+ </div>
391
+ );
392
+ }
393
+
325
394
  // ── Overlays & feedback ──────────────────────────────────────────────────────
326
395
 
327
396
  /** Esc-to-close + body scroll-lock while `open`. Shared by Dialog + Drawer. */
@@ -461,11 +530,7 @@ export function ConfirmDialog({
461
530
  footer={
462
531
  <>
463
532
  <Button onClick={onClose}>{cancelLabel}</Button>
464
- <Button
465
- variant="primary"
466
- className={cx(destructive && "pl-btn--danger")}
467
- onClick={() => onConfirm?.()}
468
- >
533
+ <Button variant={destructive ? "danger" : "primary"} onClick={() => onConfirm?.()}>
469
534
  {confirmLabel}
470
535
  </Button>
471
536
  </>
@@ -691,9 +756,16 @@ export function Spinner({ size = 16, className }: { size?: number; className?: s
691
756
  );
692
757
  }
693
758
 
694
- /** Scroll container with brand-styled thin scrollbars. */
695
- export function ScrollArea({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
696
- return <div className={cx("pl-scroll", className)} {...rest} />;
759
+ /** Scroll container with brand-styled thin scrollbars. Carries `min-height:0`
760
+ * (so it scrolls inside flex/grid parents) + overscroll containment. Pass
761
+ * `ariaLabel` to make it a keyboard-focusable, labelled scroll region. */
762
+ export function ScrollArea({
763
+ ariaLabel,
764
+ className,
765
+ ...rest
766
+ }: HTMLAttributes<HTMLDivElement> & { ariaLabel?: string }) {
767
+ const a11y = ariaLabel != null ? { role: "region", "aria-label": ariaLabel, tabIndex: 0 } : {};
768
+ return <div className={cx("pl-scroll", className)} {...a11y} {...rest} />;
697
769
  }
698
770
 
699
771
  // ── Form controls (compose with Field, or use standalone) ────────────────────
@@ -771,3 +843,182 @@ export function Checkbox({
771
843
  </label>
772
844
  );
773
845
  }
846
+
847
+ // ── Menu / DropdownMenu (Radix-backed) ───────────────────────────────────────
848
+ // Radix owns keyboard nav, focus management, and collision-aware positioning —
849
+ // the reason this lives in the DS rather than being re-rolled per app. Styling
850
+ // is token-only over --pl-*. Supports a standard click trigger AND imperative
851
+ // open-at-coordinates for right-click / context menus.
852
+
853
+ export type MenuHandle = {
854
+ /** Open the menu. Pass viewport coords (e.g. from a contextmenu event) to
855
+ * open at a point; omit to open at the default anchor. */
856
+ open: (coords?: { x: number; y: number }) => void;
857
+ close: () => void;
858
+ };
859
+
860
+ export type MenuProps = {
861
+ /** Standard trigger (click to open). Omit and drive via the ref's
862
+ * open({x,y}) for right-click / imperative menus. */
863
+ trigger?: ReactNode;
864
+ children: ReactNode;
865
+ align?: "start" | "center" | "end";
866
+ /** Fires on open/close — e.g. to clear the app's context state on dismiss. */
867
+ onOpenChange?: (open: boolean) => void;
868
+ };
869
+
870
+ export const Menu = forwardRef<MenuHandle, MenuProps>(function Menu(
871
+ { trigger, children, align = "start", onOpenChange },
872
+ ref,
873
+ ) {
874
+ const [open, setOpen] = useState(false);
875
+ const [coords, setCoords] = useState<{ x: number; y: number } | null>(null);
876
+ const setOpenState = useCallback(
877
+ (o: boolean) => {
878
+ setOpen(o);
879
+ onOpenChange?.(o);
880
+ },
881
+ [onOpenChange],
882
+ );
883
+ useImperativeHandle(
884
+ ref,
885
+ () => ({
886
+ open: (c) => {
887
+ setCoords(c ?? null);
888
+ setOpenState(true);
889
+ },
890
+ close: () => setOpenState(false),
891
+ }),
892
+ [setOpenState],
893
+ );
894
+ return (
895
+ <RDropdown.Root open={open} onOpenChange={setOpenState} modal={false}>
896
+ {trigger != null ? (
897
+ <RDropdown.Trigger asChild>{trigger}</RDropdown.Trigger>
898
+ ) : (
899
+ <RDropdown.Trigger asChild>
900
+ <span
901
+ aria-hidden
902
+ className="pl-menu__anchor"
903
+ style={coords ? { position: "fixed", left: coords.x, top: coords.y } : undefined}
904
+ />
905
+ </RDropdown.Trigger>
906
+ )}
907
+ <RDropdown.Portal>
908
+ <RDropdown.Content className="pl-menu" align={align} sideOffset={4} collisionPadding={8} loop>
909
+ {children}
910
+ </RDropdown.Content>
911
+ </RDropdown.Portal>
912
+ </RDropdown.Root>
913
+ );
914
+ });
915
+
916
+ export function MenuItem({
917
+ icon,
918
+ disabled,
919
+ destructive,
920
+ onSelect,
921
+ children,
922
+ }: {
923
+ icon?: ReactNode;
924
+ disabled?: boolean;
925
+ /** Error-toned (delete, remove, etc.). */
926
+ destructive?: boolean;
927
+ onSelect?: () => void;
928
+ children: ReactNode;
929
+ }) {
930
+ return (
931
+ <RDropdown.Item
932
+ className={cx("pl-menu__item", destructive && "pl-menu__item--destructive")}
933
+ disabled={disabled}
934
+ onSelect={onSelect}
935
+ >
936
+ {icon != null && (
937
+ <span className="pl-menu__icon" aria-hidden>
938
+ {icon}
939
+ </span>
940
+ )}
941
+ <span className="pl-menu__label">{children}</span>
942
+ </RDropdown.Item>
943
+ );
944
+ }
945
+
946
+ export function MenuSeparator() {
947
+ return <RDropdown.Separator className="pl-menu__sep" />;
948
+ }
949
+
950
+ export function MenuLabel({ children }: { children: ReactNode }) {
951
+ return <RDropdown.Label className="pl-menu__group-label">{children}</RDropdown.Label>;
952
+ }
953
+
954
+ /** Nested submenu — put MenuItem/MenuSeparator children inside. */
955
+ export function MenuSub({
956
+ label,
957
+ icon,
958
+ children,
959
+ }: {
960
+ label: ReactNode;
961
+ icon?: ReactNode;
962
+ children: ReactNode;
963
+ }) {
964
+ return (
965
+ <RDropdown.Sub>
966
+ <RDropdown.SubTrigger className="pl-menu__item pl-menu__subtrigger">
967
+ {icon != null && (
968
+ <span className="pl-menu__icon" aria-hidden>
969
+ {icon}
970
+ </span>
971
+ )}
972
+ <span className="pl-menu__label">{label}</span>
973
+ <span className="pl-menu__subarrow" aria-hidden>
974
+
975
+ </span>
976
+ </RDropdown.SubTrigger>
977
+ <RDropdown.Portal>
978
+ <RDropdown.SubContent className="pl-menu" sideOffset={2} alignOffset={-4} collisionPadding={8}>
979
+ {children}
980
+ </RDropdown.SubContent>
981
+ </RDropdown.Portal>
982
+ </RDropdown.Sub>
983
+ );
984
+ }
985
+
986
+ // ── Skeleton (loading placeholder) ───────────────────────────────────────────
987
+
988
+ /** Shimmering content-shaped loading placeholder. `lines>1` stacks text bars
989
+ * (last one short). Token-driven; static fill under reduced-motion. */
990
+ export function Skeleton({
991
+ width,
992
+ height = 14,
993
+ lines,
994
+ className,
995
+ style,
996
+ ...rest
997
+ }: HTMLAttributes<HTMLDivElement> & {
998
+ width?: number | string;
999
+ height?: number | string;
1000
+ /** Stack N text-line bars instead of a single bar. */
1001
+ lines?: number;
1002
+ }) {
1003
+ if (lines != null && lines > 1) {
1004
+ return (
1005
+ <div className={cx("pl-skel-lines", className)} style={style} {...rest}>
1006
+ {Array.from({ length: lines }, (_, i) => (
1007
+ <div
1008
+ key={i}
1009
+ className="pl-skel"
1010
+ style={{ height, width: i === lines - 1 ? "65%" : (width ?? "100%") }}
1011
+ />
1012
+ ))}
1013
+ </div>
1014
+ );
1015
+ }
1016
+ return (
1017
+ <div className={cx("pl-skel", className)} style={{ width: width ?? "100%", height, ...style }} {...rest} />
1018
+ );
1019
+ }
1020
+
1021
+ /** Optional wrapper to group related skeletons (shared layout gap). */
1022
+ export function SkeletonGroup({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
1023
+ return <div className={cx("pl-skel-group", className)} {...rest} />;
1024
+ }
package/src/styles.css CHANGED
@@ -472,6 +472,9 @@ a.pl-row:hover {
472
472
  border-bottom: var(--pl-border-width) solid var(--pl-color-border);
473
473
  }
474
474
  .pl-tab {
475
+ display: inline-flex;
476
+ align-items: center;
477
+ gap: 6px;
475
478
  background: none;
476
479
  border: none;
477
480
  border-bottom: 2px solid transparent;
@@ -482,6 +485,32 @@ a.pl-row:hover {
482
485
  cursor: pointer;
483
486
  border-radius: var(--pl-radius) var(--pl-radius) 0 0;
484
487
  }
488
+ .pl-tab__icon {
489
+ display: inline-flex;
490
+ align-items: center;
491
+ }
492
+ .pl-tab__icon svg {
493
+ width: 15px;
494
+ height: 15px;
495
+ }
496
+ .pl-tab__badge {
497
+ display: inline-flex;
498
+ align-items: center;
499
+ justify-content: center;
500
+ min-width: 16px;
501
+ height: 16px;
502
+ padding: 0 5px;
503
+ font-family: var(--pl-font-mono);
504
+ font-size: 10px;
505
+ line-height: 1;
506
+ color: var(--pl-color-fg-muted);
507
+ background: var(--pl-color-bg-subtle);
508
+ border: var(--pl-border-width) solid var(--pl-color-border);
509
+ border-radius: 999px;
510
+ }
511
+ .pl-tab--active .pl-tab__badge {
512
+ color: var(--pl-color-fg);
513
+ }
485
514
  .pl-tab:hover:not(:disabled) {
486
515
  color: var(--pl-color-fg);
487
516
  }
@@ -579,6 +608,43 @@ a.pl-row:hover {
579
608
  border-color: var(--pl-color-fg);
580
609
  }
581
610
 
611
+ /* ── PanelHeader (console panel header) ──────────────────────────────────────── */
612
+ .pl-panel-header {
613
+ display: flex;
614
+ align-items: center;
615
+ justify-content: space-between;
616
+ gap: var(--pl-space-3);
617
+ padding: var(--pl-space-3) var(--pl-space-4);
618
+ border-bottom: var(--pl-border-width) solid var(--pl-color-border);
619
+ }
620
+ .pl-panel-header__kicker {
621
+ font-family: var(--pl-font-mono);
622
+ font-size: 11px;
623
+ text-transform: uppercase;
624
+ letter-spacing: 0.06em;
625
+ color: var(--pl-color-fg-muted);
626
+ margin-bottom: 2px;
627
+ }
628
+ .pl-panel-header__title {
629
+ margin: 0;
630
+ font-size: 15px;
631
+ font-weight: var(--pl-font-weight-medium);
632
+ line-height: 1.2;
633
+ color: var(--pl-color-fg);
634
+ }
635
+ .pl-panel-header__actions {
636
+ display: flex;
637
+ align-items: center;
638
+ gap: var(--pl-space-2);
639
+ flex-shrink: 0;
640
+ }
641
+ .pl-panel-header--compact {
642
+ padding: var(--pl-space-2) var(--pl-space-3);
643
+ }
644
+ .pl-panel-header--compact .pl-panel-header__title {
645
+ font-size: 13px;
646
+ }
647
+
582
648
  /* ── Overlay scrim (Dialog + Drawer) ─────────────────────────────────────────── */
583
649
  .pl-overlay {
584
650
  position: fixed;
@@ -668,6 +734,33 @@ a.pl-row:hover {
668
734
  border-color: var(--pl-color-status-error);
669
735
  color: var(--pl-color-bg);
670
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
+ }
671
764
 
672
765
  /* ── Drawer (slide-in sheet) ─────────────────────────────────────────────────── */
673
766
  .pl-drawer {
@@ -941,9 +1034,18 @@ a.pl-row:hover {
941
1034
  /* ── ScrollArea (thin brand scrollbars) ──────────────────────────────────────── */
942
1035
  .pl-scroll {
943
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;
944
1042
  scrollbar-width: thin;
945
1043
  scrollbar-color: var(--pl-color-border-strong) transparent;
946
1044
  }
1045
+ .pl-scroll:focus-visible {
1046
+ outline: 1px solid var(--pl-color-fg);
1047
+ outline-offset: -2px;
1048
+ }
947
1049
  .pl-scroll::-webkit-scrollbar {
948
1050
  width: 8px;
949
1051
  height: 8px;
@@ -1097,6 +1199,113 @@ a.pl-row:hover {
1097
1199
  color: var(--pl-color-fg);
1098
1200
  }
1099
1201
 
1202
+ /* ── Menu / DropdownMenu (Radix-backed) ──────────────────────────────────────── */
1203
+ .pl-menu__anchor {
1204
+ width: 0;
1205
+ height: 0;
1206
+ pointer-events: none;
1207
+ }
1208
+ .pl-menu {
1209
+ min-width: 180px;
1210
+ padding: 4px;
1211
+ background: var(--pl-color-bg-raised);
1212
+ border: var(--pl-border-width) solid var(--pl-color-border-strong);
1213
+ border-radius: var(--pl-radius);
1214
+ box-shadow: var(--pl-shadow-popover);
1215
+ z-index: 1200;
1216
+ }
1217
+ .pl-menu__item {
1218
+ display: flex;
1219
+ align-items: center;
1220
+ gap: 8px;
1221
+ padding: 6px 8px;
1222
+ font-size: 13px;
1223
+ color: var(--pl-color-fg);
1224
+ border-radius: calc(var(--pl-radius) - 1px);
1225
+ cursor: pointer;
1226
+ outline: none;
1227
+ user-select: none;
1228
+ }
1229
+ /* Radix sets data-highlighted on the keyboard/pointer-focused item. */
1230
+ .pl-menu__item[data-highlighted] {
1231
+ background: var(--pl-color-bg-hover);
1232
+ }
1233
+ .pl-menu__item[data-disabled] {
1234
+ color: var(--pl-color-fg-muted);
1235
+ opacity: 0.5;
1236
+ pointer-events: none;
1237
+ }
1238
+ .pl-menu__item--destructive {
1239
+ color: var(--pl-color-status-error);
1240
+ }
1241
+ .pl-menu__item--destructive[data-highlighted] {
1242
+ background: color-mix(in oklch, var(--pl-color-status-error) 16%, transparent);
1243
+ }
1244
+ .pl-menu__icon {
1245
+ display: inline-flex;
1246
+ align-items: center;
1247
+ color: var(--pl-color-fg-muted);
1248
+ }
1249
+ .pl-menu__icon svg {
1250
+ width: 15px;
1251
+ height: 15px;
1252
+ }
1253
+ .pl-menu__item--destructive .pl-menu__icon {
1254
+ color: var(--pl-color-status-error);
1255
+ }
1256
+ .pl-menu__label {
1257
+ flex: 1;
1258
+ }
1259
+ .pl-menu__subarrow {
1260
+ color: var(--pl-color-fg-muted);
1261
+ font-size: 14px;
1262
+ line-height: 1;
1263
+ }
1264
+ .pl-menu__sep {
1265
+ height: 1px;
1266
+ margin: 4px 0;
1267
+ background: var(--pl-color-border);
1268
+ }
1269
+ .pl-menu__group-label {
1270
+ padding: 4px 8px;
1271
+ font-family: var(--pl-font-mono);
1272
+ font-size: 10px;
1273
+ text-transform: uppercase;
1274
+ letter-spacing: 0.06em;
1275
+ color: var(--pl-color-fg-muted);
1276
+ }
1277
+
1278
+ /* ── Skeleton (loading placeholder) ──────────────────────────────────────────── */
1279
+ .pl-skel {
1280
+ display: block;
1281
+ border-radius: var(--pl-radius);
1282
+ background-color: var(--pl-color-bg-subtle);
1283
+ background-image: linear-gradient(
1284
+ 90deg,
1285
+ var(--pl-color-bg-subtle) 25%,
1286
+ var(--pl-color-bg-hover) 50%,
1287
+ var(--pl-color-bg-subtle) 75%
1288
+ );
1289
+ background-size: 200% 100%;
1290
+ animation: pl-skel-shimmer 1.4s var(--pl-motion-ease-in-out) infinite;
1291
+ }
1292
+ @keyframes pl-skel-shimmer {
1293
+ from {
1294
+ background-position: 200% 0;
1295
+ }
1296
+ to {
1297
+ background-position: -200% 0;
1298
+ }
1299
+ }
1300
+ .pl-skel-lines {
1301
+ display: grid;
1302
+ gap: 8px;
1303
+ }
1304
+ .pl-skel-group {
1305
+ display: grid;
1306
+ gap: var(--pl-space-3);
1307
+ }
1308
+
1100
1309
  /* Respect reduced-motion for every animation this package introduces. */
1101
1310
  @media (prefers-reduced-motion: reduce) {
1102
1311
  .pl-drawer,
@@ -1105,4 +1314,9 @@ a.pl-row:hover {
1105
1314
  .pl-spinner {
1106
1315
  animation: none;
1107
1316
  }
1317
+ /* Skeletons go static (solid fill, no shimmer). */
1318
+ .pl-skel {
1319
+ animation: none;
1320
+ background-image: none;
1321
+ }
1108
1322
  }