@protolabsai/ui 0.20.0 → 0.21.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.
@@ -336,6 +336,42 @@ a:hover {
336
336
  padding: 1rem 0;
337
337
  }
338
338
 
339
+ /* Slotted form (icon/title/description/action) — centered scaffold. */
340
+ .pl-empty--slotted {
341
+ display: flex;
342
+ flex-direction: column;
343
+ align-items: center;
344
+ justify-content: center;
345
+ gap: var(--pl-space-2);
346
+ padding: var(--pl-space-6) var(--pl-space-4);
347
+ text-align: center;
348
+ }
349
+ .pl-empty__icon {
350
+ display: inline-flex;
351
+ margin-bottom: var(--pl-space-1);
352
+ color: var(--pl-color-fg-subtle);
353
+ }
354
+ .pl-empty__icon svg {
355
+ width: 28px;
356
+ height: 28px;
357
+ }
358
+ .pl-empty__title {
359
+ font-family: var(--pl-font-sans);
360
+ font-size: 14px;
361
+ font-weight: var(--pl-font-weight-medium);
362
+ color: var(--pl-color-fg);
363
+ }
364
+ .pl-empty__desc {
365
+ max-width: 44ch;
366
+ font-family: var(--pl-font-sans);
367
+ font-size: 13px;
368
+ line-height: 1.6;
369
+ color: var(--pl-color-fg-muted);
370
+ }
371
+ .pl-empty__action {
372
+ margin-top: var(--pl-space-2);
373
+ }
374
+
339
375
  /* ── divider ── */
340
376
  .pl-divider {
341
377
  border: 0;
@@ -572,6 +608,49 @@ a:hover {
572
608
  }
573
609
  }
574
610
 
611
+ /* ── Grid (responsive card grid) ── */
612
+ .pl-grid {
613
+ display: grid;
614
+ }
615
+ .pl-grid--gap-sm {
616
+ gap: var(--pl-space-2);
617
+ }
618
+ .pl-grid--gap-md {
619
+ gap: var(--pl-space-4);
620
+ }
621
+ .pl-grid--gap-lg {
622
+ gap: var(--pl-space-6);
623
+ }
624
+ /* auto-fill: as many columns of >= --pl-grid-min as fit */
625
+ .pl-grid--auto {
626
+ grid-template-columns: repeat(auto-fill, minmax(var(--pl-grid-min, 14rem), 1fr));
627
+ }
628
+ /* fixed/responsive column count — each breakpoint falls back to the nearest
629
+ smaller value that was set, so `cols={{ base:1, md:2, xl:3 }}` cascades cleanly */
630
+ .pl-grid--cols {
631
+ grid-template-columns: repeat(var(--pl-grid-cols, 1), minmax(0, 1fr));
632
+ }
633
+ @media (min-width: 640px) {
634
+ .pl-grid--cols {
635
+ grid-template-columns: repeat(var(--pl-grid-cols-sm, var(--pl-grid-cols, 1)), minmax(0, 1fr));
636
+ }
637
+ }
638
+ @media (min-width: 768px) {
639
+ .pl-grid--cols {
640
+ grid-template-columns: repeat(var(--pl-grid-cols-md, var(--pl-grid-cols-sm, var(--pl-grid-cols, 1))), minmax(0, 1fr));
641
+ }
642
+ }
643
+ @media (min-width: 1024px) {
644
+ .pl-grid--cols {
645
+ grid-template-columns: repeat(var(--pl-grid-cols-lg, var(--pl-grid-cols-md, var(--pl-grid-cols-sm, var(--pl-grid-cols, 1)))), minmax(0, 1fr));
646
+ }
647
+ }
648
+ @media (min-width: 1280px) {
649
+ .pl-grid--cols {
650
+ grid-template-columns: repeat(var(--pl-grid-cols-xl, var(--pl-grid-cols-lg, var(--pl-grid-cols-md, var(--pl-grid-cols-sm, var(--pl-grid-cols, 1))))), minmax(0, 1fr));
651
+ }
652
+ }
653
+
575
654
  /* ── row (label | body [| status]) ── */
576
655
  .pl-row {
577
656
  display: grid;
@@ -1282,6 +1361,137 @@ a.pl-changelog__version:hover {
1282
1361
  color: var(--pl-color-fg-muted);
1283
1362
  }
1284
1363
 
1364
+ /* ── TabBar (browser-style session tabs) ────────────────────────────────────── */
1365
+ /* On a narrow window session tabs scroll horizontally (browser/VS Code behaviour) —
1366
+ they do NOT collapse to a <select> like Tabs, because a select can't host the
1367
+ per-tab close / inline rename / add-new affordances that are the point of TabBar. */
1368
+ .pl-tabbar {
1369
+ display: flex;
1370
+ align-items: stretch;
1371
+ gap: 2px;
1372
+ overflow-x: auto;
1373
+ scrollbar-width: thin;
1374
+ border-bottom: var(--pl-border-width) solid var(--pl-color-border);
1375
+ }
1376
+ .pl-tabbar__tab {
1377
+ display: inline-flex;
1378
+ align-items: center;
1379
+ gap: 7px;
1380
+ flex: 0 1 auto;
1381
+ min-width: 92px;
1382
+ max-width: 220px;
1383
+ padding: 7px 10px 7px 12px;
1384
+ background: none;
1385
+ border: none;
1386
+ border-bottom: 2px solid transparent;
1387
+ color: var(--pl-color-fg-muted);
1388
+ font-family: var(--pl-font-sans);
1389
+ font-size: 13px;
1390
+ cursor: pointer;
1391
+ border-radius: var(--pl-radius) var(--pl-radius) 0 0;
1392
+ transition:
1393
+ background var(--pl-motion-fast) var(--pl-motion-ease),
1394
+ color var(--pl-motion-fast) var(--pl-motion-ease);
1395
+ }
1396
+ .pl-tabbar__tab:hover {
1397
+ background: var(--pl-color-bg-hover);
1398
+ color: var(--pl-color-fg);
1399
+ }
1400
+ .pl-tabbar__tab:focus-visible {
1401
+ outline: 2px solid var(--pl-color-accent);
1402
+ outline-offset: -2px;
1403
+ }
1404
+ .pl-tabbar__tab--active {
1405
+ color: var(--pl-color-fg);
1406
+ background: var(--pl-color-bg-subtle);
1407
+ border-bottom-color: var(--pl-color-accent);
1408
+ }
1409
+ .pl-tabbar__icon {
1410
+ display: inline-flex;
1411
+ align-items: center;
1412
+ }
1413
+ .pl-tabbar__icon svg {
1414
+ width: 15px;
1415
+ height: 15px;
1416
+ }
1417
+ .pl-tabbar__label {
1418
+ min-width: 0;
1419
+ overflow: hidden;
1420
+ text-overflow: ellipsis;
1421
+ white-space: nowrap;
1422
+ }
1423
+ .pl-tabbar__edit {
1424
+ min-width: 60px;
1425
+ max-width: 140px;
1426
+ padding: 1px 4px;
1427
+ font: inherit;
1428
+ font-size: 13px;
1429
+ color: var(--pl-color-fg);
1430
+ background: var(--pl-color-bg-inset);
1431
+ border: var(--pl-border-width) solid var(--pl-color-accent);
1432
+ border-radius: calc(var(--pl-radius) - 2px);
1433
+ outline: none;
1434
+ }
1435
+ .pl-tabbar__badge {
1436
+ display: inline-flex;
1437
+ align-items: center;
1438
+ justify-content: center;
1439
+ min-width: 16px;
1440
+ height: 16px;
1441
+ padding: 0 5px;
1442
+ font-family: var(--pl-font-mono);
1443
+ font-size: 10px;
1444
+ line-height: 1;
1445
+ color: var(--pl-color-fg-muted);
1446
+ background: var(--pl-color-bg-subtle);
1447
+ border: var(--pl-border-width) solid var(--pl-color-border);
1448
+ border-radius: 999px;
1449
+ }
1450
+ .pl-tabbar__close {
1451
+ display: inline-flex;
1452
+ align-items: center;
1453
+ justify-content: center;
1454
+ width: 18px;
1455
+ height: 18px;
1456
+ margin-right: -3px;
1457
+ padding: 0;
1458
+ color: var(--pl-color-fg-subtle);
1459
+ background: none;
1460
+ border: none;
1461
+ border-radius: var(--pl-radius);
1462
+ cursor: pointer;
1463
+ opacity: 0.7;
1464
+ transition:
1465
+ background var(--pl-motion-fast) var(--pl-motion-ease),
1466
+ color var(--pl-motion-fast) var(--pl-motion-ease),
1467
+ opacity var(--pl-motion-fast) var(--pl-motion-ease);
1468
+ }
1469
+ .pl-tabbar__close:hover {
1470
+ color: var(--pl-color-fg);
1471
+ background: var(--pl-color-bg-hover);
1472
+ opacity: 1;
1473
+ }
1474
+ .pl-tabbar__add {
1475
+ display: inline-flex;
1476
+ align-items: center;
1477
+ justify-content: center;
1478
+ width: 28px;
1479
+ flex-shrink: 0;
1480
+ margin-left: 2px;
1481
+ color: var(--pl-color-fg-muted);
1482
+ background: none;
1483
+ border: none;
1484
+ cursor: pointer;
1485
+ border-radius: var(--pl-radius);
1486
+ transition:
1487
+ background var(--pl-motion-fast) var(--pl-motion-ease),
1488
+ color var(--pl-motion-fast) var(--pl-motion-ease);
1489
+ }
1490
+ .pl-tabbar__add:hover {
1491
+ color: var(--pl-color-fg);
1492
+ background: var(--pl-color-bg-hover);
1493
+ }
1494
+
1285
1495
  /* ── ui component: forms.css ───────────────────────────────────────────────── */
1286
1496
  /* @protolabsai/ui — forms styles (over @protolabsai/design --pl-* tokens). */
1287
1497
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protolabsai/ui",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -0,0 +1,32 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Empty, Button } from "./primitives";
3
+
4
+ const meta: Meta<typeof Empty> = { title: "Components/Primitives/Empty" };
5
+ export default meta;
6
+ type Story = StoryObj<typeof Empty>;
7
+
8
+ const Inbox = () => (
9
+ <svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
10
+ <path d="M22 12h-6l-2 3h-4l-2-3H2" />
11
+ <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
12
+ </svg>
13
+ );
14
+
15
+ /** Slotted: icon + title + description + action — the standard scaffold. */
16
+ export const Slotted: Story = {
17
+ render: () => (
18
+ <div style={{ maxWidth: 420, border: "1px solid var(--pl-color-border)", borderRadius: 8 }}>
19
+ <Empty
20
+ icon={<Inbox />}
21
+ title="No agents yet"
22
+ description="Discover protoAgents on your network to add them as delegates."
23
+ action={<Button>Discover</Button>}
24
+ />
25
+ </div>
26
+ ),
27
+ };
28
+
29
+ /** Bare form still works unchanged (back-compat). */
30
+ export const Bare: Story = {
31
+ render: () => <Empty>no open contracts</Empty>,
32
+ };
@@ -0,0 +1,37 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Grid } from "./layout";
3
+ import { Card } from "./primitives";
4
+
5
+ const meta: Meta<typeof Grid> = { title: "Components/Layout/Grid" };
6
+ export default meta;
7
+ type Story = StoryObj<typeof Grid>;
8
+
9
+ const cards = (n: number) =>
10
+ Array.from({ length: n }, (_, i) => (
11
+ <Card key={i} style={{ padding: 16, fontFamily: "var(--pl-font-mono)", fontSize: 13, color: "var(--pl-color-fg-muted)" }}>
12
+ card {i + 1}
13
+ </Card>
14
+ ));
15
+
16
+ /** `min` — auto-fill: as many columns of ≥ the floor as fit. Resize the canvas. */
17
+ export const AutoFill: Story = {
18
+ render: () => <Grid min="14rem" gap="md">{cards(8)}</Grid>,
19
+ };
20
+
21
+ /** `cols` — responsive count, cascading by breakpoint (1 → 2 at md → 3 at xl). */
22
+ export const ResponsiveCols: Story = {
23
+ render: () => (
24
+ <Grid cols={{ base: 1, md: 2, xl: 3 }} gap="md">
25
+ {cards(6)}
26
+ </Grid>
27
+ ),
28
+ };
29
+
30
+ /** `cols` — a fixed count. */
31
+ export const FixedCols: Story = {
32
+ render: () => (
33
+ <Grid cols={4} gap="lg">
34
+ {cards(8)}
35
+ </Grid>
36
+ ),
37
+ };
@@ -0,0 +1,74 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { TabBar } from "./navigation";
4
+ import type { TabBarItem } from "./navigation";
5
+
6
+ const meta: Meta = { title: "Components/Navigation/TabBar" };
7
+ export default meta;
8
+ type Story = StoryObj;
9
+
10
+ /** Full browser-style session tabs: close ✕, double-click to rename, "+" to add.
11
+ * Drop any callback to hide its affordance. On a narrow canvas the strip scrolls
12
+ * (it does NOT collapse to a select — that can't carry close/rename/add). */
13
+ export const Sessions: Story = {
14
+ render: () => {
15
+ function Demo() {
16
+ const [tabs, setTabs] = useState<TabBarItem[]>([
17
+ { id: "a", label: "research" },
18
+ { id: "b", label: "draft", badge: 3 },
19
+ { id: "c", label: "review" },
20
+ ]);
21
+ const [active, setActive] = useState("a");
22
+ let seq = tabs.length;
23
+ return (
24
+ <div style={{ maxWidth: 520 }}>
25
+ <TabBar
26
+ ariaLabel="Sessions"
27
+ items={tabs}
28
+ activeId={active}
29
+ onSelect={setActive}
30
+ onClose={(id) => {
31
+ setTabs((ts) => {
32
+ const next = ts.filter((t) => t.id !== id);
33
+ if (id === active && next[0]) setActive(next[0].id);
34
+ return next;
35
+ });
36
+ }}
37
+ onRename={(id, label) => setTabs((ts) => ts.map((t) => (t.id === id ? { ...t, label } : t)))}
38
+ onAdd={() => {
39
+ const id = `s${++seq}`;
40
+ setTabs((ts) => [...ts, { id, label: `session ${seq}` }]);
41
+ setActive(id);
42
+ }}
43
+ />
44
+ <p style={{ fontFamily: "var(--pl-font-mono)", fontSize: 12, color: "var(--pl-color-fg-muted)" }}>
45
+ active: {active} · double-click a tab to rename
46
+ </p>
47
+ </div>
48
+ );
49
+ }
50
+ return <Demo />;
51
+ },
52
+ };
53
+
54
+ /** Read-only: no callbacks beyond select → degrades to a plain strip. */
55
+ export const SelectOnly: Story = {
56
+ render: () => {
57
+ function Demo() {
58
+ const [active, setActive] = useState("a");
59
+ return (
60
+ <TabBar
61
+ ariaLabel="Views"
62
+ items={[
63
+ { id: "a", label: "overview" },
64
+ { id: "b", label: "activity" },
65
+ { id: "c", label: "settings" },
66
+ ]}
67
+ activeId={active}
68
+ onSelect={setActive}
69
+ />
70
+ );
71
+ }
72
+ return <Demo />;
73
+ },
74
+ };
package/src/layout.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { HTMLAttributes, ReactNode } from "react";
1
+ import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
2
2
  import { cx } from "./internal";
3
3
 
4
4
  export function Stat({ value, label }: { value: ReactNode; label: ReactNode }) {
@@ -23,6 +23,45 @@ export function Stats({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
23
23
  return <div className={cx("pl-stats", className)} {...rest} />;
24
24
  }
25
25
 
26
+ type GridCols = number | { base?: number; sm?: number; md?: number; lg?: number; xl?: number };
27
+
28
+ /** Responsive card grid — the generic `repeat(auto-fill, minmax())` every surface
29
+ * was re-rolling. Pass `min` (auto-fill floor — packs in as many columns as fit)
30
+ * OR `cols` (a fixed count, or a per-breakpoint map). `gap` is on the token scale. */
31
+ export function Grid({
32
+ min,
33
+ cols,
34
+ gap = "md",
35
+ className,
36
+ style,
37
+ ...rest
38
+ }: HTMLAttributes<HTMLDivElement> & {
39
+ /** Auto-fill column floor, e.g. "14rem". Columns wrap to fit the container. */
40
+ min?: string;
41
+ /** Fixed column count, or per-breakpoint `{ base, sm, md, lg, xl }`. Ignored if `min` is set. */
42
+ cols?: GridCols;
43
+ gap?: "sm" | "md" | "lg";
44
+ }) {
45
+ const vars: Record<string, string> = {};
46
+ if (min) {
47
+ vars["--pl-grid-min"] = min;
48
+ } else if (cols != null) {
49
+ const c = typeof cols === "number" ? { base: cols } : cols;
50
+ if (c.base != null) vars["--pl-grid-cols"] = String(c.base);
51
+ if (c.sm != null) vars["--pl-grid-cols-sm"] = String(c.sm);
52
+ if (c.md != null) vars["--pl-grid-cols-md"] = String(c.md);
53
+ if (c.lg != null) vars["--pl-grid-cols-lg"] = String(c.lg);
54
+ if (c.xl != null) vars["--pl-grid-cols-xl"] = String(c.xl);
55
+ }
56
+ return (
57
+ <div
58
+ className={cx("pl-grid", min ? "pl-grid--auto" : "pl-grid--cols", `pl-grid--gap-${gap}`, className)}
59
+ style={{ ...vars, ...style } as CSSProperties}
60
+ {...rest}
61
+ />
62
+ );
63
+ }
64
+
26
65
  export type RowProps = {
27
66
  /** Left mono label / layer. */
28
67
  label: string;
@@ -80,6 +80,125 @@ export function Tabs({
80
80
  );
81
81
  }
82
82
 
83
+ export type TabBarItem = {
84
+ id: string;
85
+ label: string;
86
+ icon?: ReactNode;
87
+ badge?: ReactNode;
88
+ };
89
+
90
+ /** Browser-style session tabs: closable, inline-renamable, with an add-new "+".
91
+ * Every interaction callback is optional — absence hides its affordance, so with
92
+ * just `items`/`activeId`/`onSelect` it degrades to a plain tab strip. Pass
93
+ * `onClose` for per-tab ✕, `onRename` to enable double-click-to-edit, `onAdd` for
94
+ * the trailing "+". */
95
+ export function TabBar({
96
+ items,
97
+ activeId,
98
+ onSelect,
99
+ onClose,
100
+ onRename,
101
+ onAdd,
102
+ addLabel = "New tab",
103
+ ariaLabel,
104
+ }: {
105
+ items: TabBarItem[];
106
+ activeId: string;
107
+ onSelect: (id: string) => void;
108
+ onClose?: (id: string) => void;
109
+ onRename?: (id: string, label: string) => void;
110
+ onAdd?: () => void;
111
+ addLabel?: string;
112
+ ariaLabel?: string;
113
+ }) {
114
+ const [editing, setEditing] = useState<string | null>(null);
115
+ const [draft, setDraft] = useState("");
116
+ const startEdit = (t: TabBarItem) => {
117
+ if (!onRename) return;
118
+ setEditing(t.id);
119
+ setDraft(t.label);
120
+ };
121
+ const commit = () => {
122
+ if (editing != null && onRename) {
123
+ const v = draft.trim();
124
+ if (v) onRename(editing, v);
125
+ }
126
+ setEditing(null);
127
+ };
128
+ return (
129
+ <div className="pl-tabbar" role="tablist" aria-label={ariaLabel}>
130
+ {items.map((t) => (
131
+ <div
132
+ key={t.id}
133
+ role="tab"
134
+ tabIndex={0}
135
+ aria-selected={t.id === activeId}
136
+ className={cx("pl-tabbar__tab", t.id === activeId && "pl-tabbar__tab--active")}
137
+ onClick={() => editing !== t.id && onSelect(t.id)}
138
+ onDoubleClick={() => startEdit(t)}
139
+ onKeyDown={(e) => {
140
+ if (editing === t.id) return;
141
+ if (e.key === "Enter" || e.key === " ") {
142
+ e.preventDefault();
143
+ onSelect(t.id);
144
+ }
145
+ }}
146
+ >
147
+ {t.icon != null && (
148
+ <span className="pl-tabbar__icon" aria-hidden>
149
+ {t.icon}
150
+ </span>
151
+ )}
152
+ {editing === t.id ? (
153
+ <input
154
+ className="pl-tabbar__edit"
155
+ autoFocus
156
+ value={draft}
157
+ aria-label="Rename tab"
158
+ onChange={(e) => setDraft(e.target.value)}
159
+ onClick={(e) => e.stopPropagation()}
160
+ onBlur={commit}
161
+ onKeyDown={(e) => {
162
+ if (e.key === "Enter") {
163
+ e.preventDefault();
164
+ commit();
165
+ } else if (e.key === "Escape") {
166
+ setEditing(null);
167
+ }
168
+ }}
169
+ />
170
+ ) : (
171
+ <span className="pl-tabbar__label">{t.label}</span>
172
+ )}
173
+ {t.badge != null && <span className="pl-tabbar__badge">{t.badge}</span>}
174
+ {onClose && editing !== t.id && (
175
+ <button
176
+ type="button"
177
+ className="pl-tabbar__close"
178
+ aria-label={`Close ${t.label}`}
179
+ onClick={(e) => {
180
+ e.stopPropagation();
181
+ onClose(t.id);
182
+ }}
183
+ >
184
+ <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
185
+ <path d="M6 6l12 12M18 6L6 18" />
186
+ </svg>
187
+ </button>
188
+ )}
189
+ </div>
190
+ ))}
191
+ {onAdd && (
192
+ <button type="button" className="pl-tabbar__add" aria-label={addLabel} title={addLabel} onClick={onAdd}>
193
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
194
+ <path d="M12 5v14M5 12h14" />
195
+ </svg>
196
+ </button>
197
+ )}
198
+ </div>
199
+ );
200
+ }
201
+
83
202
  /** A horizontal kanban board. Wrap BoardColumn children. */
84
203
  export function Board({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
85
204
  return <div className={cx("pl-board", className)} {...rest} />;
@@ -40,9 +40,37 @@ export function Eyebrow({ children }: { children: ReactNode }) {
40
40
  return <div className="pl-eyebrow">{children}</div>;
41
41
  }
42
42
 
43
- /** Mono empty-state line. */
44
- export function Empty({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
45
- return <div className={cx("pl-empty", className)} {...rest} />;
43
+ /** Empty state. Bare `<Empty>body</Empty>` still works; pass any of the slots
44
+ * (icon / title / description / action) for the standard centered scaffold so
45
+ * surfaces stop re-composing it around the wrapper. */
46
+ export function Empty({
47
+ icon,
48
+ title,
49
+ description,
50
+ action,
51
+ className,
52
+ children,
53
+ ...rest
54
+ }: HTMLAttributes<HTMLDivElement> & {
55
+ icon?: ReactNode;
56
+ title?: ReactNode;
57
+ description?: ReactNode;
58
+ action?: ReactNode;
59
+ }) {
60
+ const slotted = icon != null || title != null || description != null || action != null;
61
+ return (
62
+ <div className={cx("pl-empty", slotted && "pl-empty--slotted", className)} {...rest}>
63
+ {icon != null && (
64
+ <div className="pl-empty__icon" aria-hidden>
65
+ {icon}
66
+ </div>
67
+ )}
68
+ {title != null && <div className="pl-empty__title">{title}</div>}
69
+ {description != null && <div className="pl-empty__desc">{description}</div>}
70
+ {children}
71
+ {action != null && <div className="pl-empty__action">{action}</div>}
72
+ </div>
73
+ );
46
74
  }
47
75
 
48
76
  /** Hairline rule. */
@@ -39,6 +39,49 @@
39
39
  }
40
40
  }
41
41
 
42
+ /* ── Grid (responsive card grid) ── */
43
+ .pl-grid {
44
+ display: grid;
45
+ }
46
+ .pl-grid--gap-sm {
47
+ gap: var(--pl-space-2);
48
+ }
49
+ .pl-grid--gap-md {
50
+ gap: var(--pl-space-4);
51
+ }
52
+ .pl-grid--gap-lg {
53
+ gap: var(--pl-space-6);
54
+ }
55
+ /* auto-fill: as many columns of >= --pl-grid-min as fit */
56
+ .pl-grid--auto {
57
+ grid-template-columns: repeat(auto-fill, minmax(var(--pl-grid-min, 14rem), 1fr));
58
+ }
59
+ /* fixed/responsive column count — each breakpoint falls back to the nearest
60
+ smaller value that was set, so `cols={{ base:1, md:2, xl:3 }}` cascades cleanly */
61
+ .pl-grid--cols {
62
+ grid-template-columns: repeat(var(--pl-grid-cols, 1), minmax(0, 1fr));
63
+ }
64
+ @media (min-width: 640px) {
65
+ .pl-grid--cols {
66
+ grid-template-columns: repeat(var(--pl-grid-cols-sm, var(--pl-grid-cols, 1)), minmax(0, 1fr));
67
+ }
68
+ }
69
+ @media (min-width: 768px) {
70
+ .pl-grid--cols {
71
+ grid-template-columns: repeat(var(--pl-grid-cols-md, var(--pl-grid-cols-sm, var(--pl-grid-cols, 1))), minmax(0, 1fr));
72
+ }
73
+ }
74
+ @media (min-width: 1024px) {
75
+ .pl-grid--cols {
76
+ grid-template-columns: repeat(var(--pl-grid-cols-lg, var(--pl-grid-cols-md, var(--pl-grid-cols-sm, var(--pl-grid-cols, 1)))), minmax(0, 1fr));
77
+ }
78
+ }
79
+ @media (min-width: 1280px) {
80
+ .pl-grid--cols {
81
+ grid-template-columns: repeat(var(--pl-grid-cols-xl, var(--pl-grid-cols-lg, var(--pl-grid-cols-md, var(--pl-grid-cols-sm, var(--pl-grid-cols, 1))))), minmax(0, 1fr));
82
+ }
83
+ }
84
+
42
85
  /* ── row (label | body [| status]) ── */
43
86
  .pl-row {
44
87
  display: grid;
@@ -255,3 +255,134 @@
255
255
  line-height: 1.55;
256
256
  color: var(--pl-color-fg-muted);
257
257
  }
258
+
259
+ /* ── TabBar (browser-style session tabs) ────────────────────────────────────── */
260
+ /* On a narrow window session tabs scroll horizontally (browser/VS Code behaviour) —
261
+ they do NOT collapse to a <select> like Tabs, because a select can't host the
262
+ per-tab close / inline rename / add-new affordances that are the point of TabBar. */
263
+ .pl-tabbar {
264
+ display: flex;
265
+ align-items: stretch;
266
+ gap: 2px;
267
+ overflow-x: auto;
268
+ scrollbar-width: thin;
269
+ border-bottom: var(--pl-border-width) solid var(--pl-color-border);
270
+ }
271
+ .pl-tabbar__tab {
272
+ display: inline-flex;
273
+ align-items: center;
274
+ gap: 7px;
275
+ flex: 0 1 auto;
276
+ min-width: 92px;
277
+ max-width: 220px;
278
+ padding: 7px 10px 7px 12px;
279
+ background: none;
280
+ border: none;
281
+ border-bottom: 2px solid transparent;
282
+ color: var(--pl-color-fg-muted);
283
+ font-family: var(--pl-font-sans);
284
+ font-size: 13px;
285
+ cursor: pointer;
286
+ border-radius: var(--pl-radius) var(--pl-radius) 0 0;
287
+ transition:
288
+ background var(--pl-motion-fast) var(--pl-motion-ease),
289
+ color var(--pl-motion-fast) var(--pl-motion-ease);
290
+ }
291
+ .pl-tabbar__tab:hover {
292
+ background: var(--pl-color-bg-hover);
293
+ color: var(--pl-color-fg);
294
+ }
295
+ .pl-tabbar__tab:focus-visible {
296
+ outline: 2px solid var(--pl-color-accent);
297
+ outline-offset: -2px;
298
+ }
299
+ .pl-tabbar__tab--active {
300
+ color: var(--pl-color-fg);
301
+ background: var(--pl-color-bg-subtle);
302
+ border-bottom-color: var(--pl-color-accent);
303
+ }
304
+ .pl-tabbar__icon {
305
+ display: inline-flex;
306
+ align-items: center;
307
+ }
308
+ .pl-tabbar__icon svg {
309
+ width: 15px;
310
+ height: 15px;
311
+ }
312
+ .pl-tabbar__label {
313
+ min-width: 0;
314
+ overflow: hidden;
315
+ text-overflow: ellipsis;
316
+ white-space: nowrap;
317
+ }
318
+ .pl-tabbar__edit {
319
+ min-width: 60px;
320
+ max-width: 140px;
321
+ padding: 1px 4px;
322
+ font: inherit;
323
+ font-size: 13px;
324
+ color: var(--pl-color-fg);
325
+ background: var(--pl-color-bg-inset);
326
+ border: var(--pl-border-width) solid var(--pl-color-accent);
327
+ border-radius: calc(var(--pl-radius) - 2px);
328
+ outline: none;
329
+ }
330
+ .pl-tabbar__badge {
331
+ display: inline-flex;
332
+ align-items: center;
333
+ justify-content: center;
334
+ min-width: 16px;
335
+ height: 16px;
336
+ padding: 0 5px;
337
+ font-family: var(--pl-font-mono);
338
+ font-size: 10px;
339
+ line-height: 1;
340
+ color: var(--pl-color-fg-muted);
341
+ background: var(--pl-color-bg-subtle);
342
+ border: var(--pl-border-width) solid var(--pl-color-border);
343
+ border-radius: 999px;
344
+ }
345
+ .pl-tabbar__close {
346
+ display: inline-flex;
347
+ align-items: center;
348
+ justify-content: center;
349
+ width: 18px;
350
+ height: 18px;
351
+ margin-right: -3px;
352
+ padding: 0;
353
+ color: var(--pl-color-fg-subtle);
354
+ background: none;
355
+ border: none;
356
+ border-radius: var(--pl-radius);
357
+ cursor: pointer;
358
+ opacity: 0.7;
359
+ transition:
360
+ background var(--pl-motion-fast) var(--pl-motion-ease),
361
+ color var(--pl-motion-fast) var(--pl-motion-ease),
362
+ opacity var(--pl-motion-fast) var(--pl-motion-ease);
363
+ }
364
+ .pl-tabbar__close:hover {
365
+ color: var(--pl-color-fg);
366
+ background: var(--pl-color-bg-hover);
367
+ opacity: 1;
368
+ }
369
+ .pl-tabbar__add {
370
+ display: inline-flex;
371
+ align-items: center;
372
+ justify-content: center;
373
+ width: 28px;
374
+ flex-shrink: 0;
375
+ margin-left: 2px;
376
+ color: var(--pl-color-fg-muted);
377
+ background: none;
378
+ border: none;
379
+ cursor: pointer;
380
+ border-radius: var(--pl-radius);
381
+ transition:
382
+ background var(--pl-motion-fast) var(--pl-motion-ease),
383
+ color var(--pl-motion-fast) var(--pl-motion-ease);
384
+ }
385
+ .pl-tabbar__add:hover {
386
+ color: var(--pl-color-fg);
387
+ background: var(--pl-color-bg-hover);
388
+ }
@@ -99,6 +99,42 @@
99
99
  padding: 1rem 0;
100
100
  }
101
101
 
102
+ /* Slotted form (icon/title/description/action) — centered scaffold. */
103
+ .pl-empty--slotted {
104
+ display: flex;
105
+ flex-direction: column;
106
+ align-items: center;
107
+ justify-content: center;
108
+ gap: var(--pl-space-2);
109
+ padding: var(--pl-space-6) var(--pl-space-4);
110
+ text-align: center;
111
+ }
112
+ .pl-empty__icon {
113
+ display: inline-flex;
114
+ margin-bottom: var(--pl-space-1);
115
+ color: var(--pl-color-fg-subtle);
116
+ }
117
+ .pl-empty__icon svg {
118
+ width: 28px;
119
+ height: 28px;
120
+ }
121
+ .pl-empty__title {
122
+ font-family: var(--pl-font-sans);
123
+ font-size: 14px;
124
+ font-weight: var(--pl-font-weight-medium);
125
+ color: var(--pl-color-fg);
126
+ }
127
+ .pl-empty__desc {
128
+ max-width: 44ch;
129
+ font-family: var(--pl-font-sans);
130
+ font-size: 13px;
131
+ line-height: 1.6;
132
+ color: var(--pl-color-fg-muted);
133
+ }
134
+ .pl-empty__action {
135
+ margin-top: var(--pl-space-2);
136
+ }
137
+
102
138
  /* ── divider ── */
103
139
  .pl-divider {
104
140
  border: 0;