@protolabsai/ui 0.22.0 → 0.23.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.
@@ -372,6 +372,45 @@ a:hover {
372
372
  margin-top: var(--pl-space-2);
373
373
  }
374
374
 
375
+ /* ── count badge (round numeric pill) — shared by Tabs / TabBar / SurfaceRail.
376
+ Distinct from .pl-badge (a lowercase text/label pill). ── */
377
+ .pl-count {
378
+ display: inline-flex;
379
+ align-items: center;
380
+ justify-content: center;
381
+ min-width: 16px;
382
+ height: 16px;
383
+ padding: 0 5px;
384
+ font-family: var(--pl-font-mono);
385
+ font-size: 10px;
386
+ line-height: 1;
387
+ color: var(--pl-color-fg-muted);
388
+ background: var(--pl-color-bg-subtle);
389
+ border: var(--pl-border-width) solid var(--pl-color-border);
390
+ border-radius: 999px;
391
+ }
392
+
393
+ /* ── icon button (bare clickable glyph) — shared base for close / add / copy /
394
+ dialog + toast dismiss. Each use adds its own size/margin delta. ── */
395
+ .pl-iconbtn {
396
+ display: inline-flex;
397
+ align-items: center;
398
+ justify-content: center;
399
+ padding: 0;
400
+ color: var(--pl-color-fg-muted);
401
+ background: none;
402
+ border: none;
403
+ border-radius: var(--pl-radius);
404
+ cursor: pointer;
405
+ transition:
406
+ background var(--pl-motion-fast) var(--pl-motion-ease),
407
+ color var(--pl-motion-fast) var(--pl-motion-ease);
408
+ }
409
+ .pl-iconbtn:hover {
410
+ color: var(--pl-color-fg);
411
+ background: var(--pl-color-bg-hover);
412
+ }
413
+
375
414
  /* ── divider ── */
376
415
  .pl-divider {
377
416
  border: 0;
@@ -1137,23 +1176,8 @@ a.pl-changelog__version:hover {
1137
1176
  height: 15px;
1138
1177
  }
1139
1178
 
1140
- .pl-tab__badge {
1141
- display: inline-flex;
1142
- align-items: center;
1143
- justify-content: center;
1144
- min-width: 16px;
1145
- height: 16px;
1146
- padding: 0 5px;
1147
- font-family: var(--pl-font-mono);
1148
- font-size: 10px;
1149
- line-height: 1;
1150
- color: var(--pl-color-fg-muted);
1151
- background: var(--pl-color-bg-subtle);
1152
- border: var(--pl-border-width) solid var(--pl-color-border);
1153
- border-radius: 999px;
1154
- }
1155
-
1156
- .pl-tab--active .pl-tab__badge {
1179
+ /* count badge is the shared .pl-count (primitives.css) */
1180
+ .pl-tab--active .pl-count {
1157
1181
  color: var(--pl-color-fg);
1158
1182
  }
1159
1183
 
@@ -1420,76 +1444,24 @@ a.pl-changelog__version:hover {
1420
1444
  text-overflow: ellipsis;
1421
1445
  white-space: nowrap;
1422
1446
  }
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
- }
1447
+ /* inline rename uses the shared EditableText control (.pl-editable__input) */
1448
+ /* count badge is the shared .pl-count (primitives.css) */
1449
+ /* close / add extend the shared .pl-iconbtn (primitives.css) — deltas only */
1450
1450
  .pl-tabbar__close {
1451
- display: inline-flex;
1452
- align-items: center;
1453
- justify-content: center;
1454
1451
  width: 18px;
1455
1452
  height: 18px;
1456
1453
  margin-right: -3px;
1457
- padding: 0;
1458
1454
  color: var(--pl-color-fg-subtle);
1459
- background: none;
1460
- border: none;
1461
- border-radius: var(--pl-radius);
1462
- cursor: pointer;
1463
1455
  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);
1456
+ transition: opacity var(--pl-motion-fast) var(--pl-motion-ease);
1468
1457
  }
1469
1458
  .pl-tabbar__close:hover {
1470
- color: var(--pl-color-fg);
1471
- background: var(--pl-color-bg-hover);
1472
1459
  opacity: 1;
1473
1460
  }
1474
1461
  .pl-tabbar__add {
1475
- display: inline-flex;
1476
- align-items: center;
1477
- justify-content: center;
1478
1462
  width: 28px;
1479
1463
  flex-shrink: 0;
1480
1464
  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
1465
  }
1494
1466
 
1495
1467
  /* ── ui component: forms.css ───────────────────────────────────────────────── */
@@ -1748,6 +1720,33 @@ a.pl-changelog__version:hover {
1748
1720
  color: var(--pl-color-fg);
1749
1721
  }
1750
1722
 
1723
+ /* ── EditableText (inline rename) ── */
1724
+ /* Both states are font:inherit so the label↔input flip is seamless in any context. */
1725
+ .pl-editable {
1726
+ cursor: text;
1727
+ border-radius: calc(var(--pl-radius) - 2px);
1728
+ }
1729
+ .pl-editable:hover {
1730
+ background: var(--pl-color-bg-hover);
1731
+ }
1732
+ .pl-editable:focus-visible {
1733
+ outline: 2px solid var(--pl-color-accent);
1734
+ outline-offset: 2px;
1735
+ }
1736
+ .pl-editable__input {
1737
+ min-width: 60px;
1738
+ padding: 1px 4px;
1739
+ font: inherit;
1740
+ color: var(--pl-color-fg);
1741
+ background: var(--pl-color-bg-inset);
1742
+ border: var(--pl-border-width) solid var(--pl-color-accent);
1743
+ border-radius: calc(var(--pl-radius) - 2px);
1744
+ outline: none;
1745
+ }
1746
+ .pl-editable__input--invalid {
1747
+ border-color: var(--pl-color-status-error);
1748
+ }
1749
+
1751
1750
  /* ── ui component: overlays.css ────────────────────────────────────────────── */
1752
1751
  /* @protolabsai/ui — overlays styles (over @protolabsai/design --pl-* tokens). */
1753
1752
 
@@ -1799,28 +1798,14 @@ a.pl-changelog__version:hover {
1799
1798
  color: var(--pl-color-fg);
1800
1799
  }
1801
1800
 
1801
+ /* extends the shared .pl-iconbtn (primitives.css) — delta only */
1802
1802
  .pl-dialog__close {
1803
- display: inline-flex;
1804
- align-items: center;
1805
- justify-content: center;
1806
1803
  width: 24px;
1807
1804
  height: 24px;
1808
1805
  font-size: 18px;
1809
1806
  line-height: 1;
1810
- color: var(--pl-color-fg-muted);
1811
- background: transparent;
1812
- border: none;
1813
- border-radius: var(--pl-radius);
1814
- cursor: pointer;
1815
- transition:
1816
- color var(--pl-motion-fast) var(--pl-motion-ease),
1817
- background var(--pl-motion-fast) var(--pl-motion-ease);
1818
1807
  }
1819
1808
 
1820
- .pl-dialog__close:hover {
1821
- color: var(--pl-color-fg);
1822
- background: var(--pl-color-bg-hover);
1823
- }
1824
1809
 
1825
1810
  .pl-dialog__body {
1826
1811
  padding: var(--pl-space-4);
@@ -1979,19 +1964,13 @@ a.pl-changelog__version:hover {
1979
1964
  color: var(--pl-color-fg-muted);
1980
1965
  }
1981
1966
 
1967
+ /* extends the shared .pl-iconbtn (primitives.css) — delta only */
1982
1968
  .pl-toast__close {
1983
1969
  flex-shrink: 0;
1984
1970
  font-size: 16px;
1985
1971
  line-height: 1;
1986
- color: var(--pl-color-fg-muted);
1987
- background: transparent;
1988
- border: none;
1989
- cursor: pointer;
1990
1972
  }
1991
1973
 
1992
- .pl-toast__close:hover {
1993
- color: var(--pl-color-fg);
1994
- }
1995
1974
 
1996
1975
  /* ── Tooltip (CSS-only) ──────────────────────────────────────────────────────── */
1997
1976
  .pl-tip-wrap {
@@ -2546,22 +2525,17 @@ a.pl-changelog__version:hover {
2546
2525
  white-space: nowrap;
2547
2526
  }
2548
2527
 
2549
- .pl-rail__badge {
2528
+ /* rail count badge = shared .pl-count (primitives.css) + absolute positioning */
2529
+ .pl-count--rail {
2550
2530
  position: absolute;
2551
2531
  top: 4px;
2552
2532
  right: 9px;
2553
- display: inline-flex;
2554
- align-items: center;
2555
- justify-content: center;
2556
2533
  min-width: 15px;
2557
2534
  height: 15px;
2558
2535
  padding: 0 4px;
2559
- font-family: var(--pl-font-mono);
2560
2536
  font-size: 9px;
2561
2537
  color: var(--pl-color-fg);
2562
- background: var(--pl-color-bg-subtle);
2563
- border: var(--pl-border-width) solid var(--pl-color-border-strong);
2564
- border-radius: 999px;
2538
+ border-color: var(--pl-color-border-strong);
2565
2539
  }
2566
2540
 
2567
2541
  .pl-rail__dot {
@@ -2896,11 +2870,16 @@ a.pl-changelog__version:hover {
2896
2870
  overflow: hidden;
2897
2871
  }
2898
2872
 
2873
+ .pl-toolcard__head-row {
2874
+ display: flex;
2875
+ align-items: center;
2876
+ }
2899
2877
  .pl-toolcard__head {
2878
+ flex: 1 1 auto;
2879
+ min-width: 0;
2900
2880
  display: flex;
2901
2881
  align-items: center;
2902
2882
  gap: 7px;
2903
- width: 100%;
2904
2883
  padding: 6px 9px;
2905
2884
  background: none;
2906
2885
  border: none;
@@ -2910,6 +2889,14 @@ a.pl-changelog__version:hover {
2910
2889
  text-align: left;
2911
2890
  cursor: pointer;
2912
2891
  }
2892
+ .pl-toolcard__actions {
2893
+ flex: none;
2894
+ display: inline-flex;
2895
+ align-items: center;
2896
+ gap: 2px;
2897
+ padding-right: 6px;
2898
+ color: var(--pl-color-fg-muted);
2899
+ }
2913
2900
  .pl-toolcard__head:disabled {
2914
2901
  cursor: default;
2915
2902
  }
@@ -2971,13 +2958,9 @@ a.pl-changelog__version:hover {
2971
2958
  .pl-toolcard__status--running {
2972
2959
  color: var(--pl-color-status-warning);
2973
2960
  }
2961
+ /* reuses the shared `pl-spin` keyframe (data.css) */
2974
2962
  .pl-toolcard__spin {
2975
- animation: pl-toolcard-spin 0.8s linear infinite;
2976
- }
2977
- @keyframes pl-toolcard-spin {
2978
- to {
2979
- transform: rotate(360deg);
2980
- }
2963
+ animation: pl-spin 0.8s linear infinite;
2981
2964
  }
2982
2965
 
2983
2966
  .pl-toolcard__body {
@@ -3019,23 +3002,9 @@ a.pl-changelog__version:hover {
3019
3002
  letter-spacing: 0.04em;
3020
3003
  color: var(--pl-color-fg-subtle);
3021
3004
  }
3005
+ /* extends the shared .pl-iconbtn (primitives.css) — delta only */
3022
3006
  .pl-toolcard__copy {
3023
- display: inline-flex;
3024
- align-items: center;
3025
- justify-content: center;
3026
3007
  padding: 2px;
3027
- background: none;
3028
- border: none;
3029
- border-radius: var(--pl-radius);
3030
- color: var(--pl-color-fg-muted);
3031
- cursor: pointer;
3032
- transition:
3033
- background var(--pl-motion-fast) var(--pl-motion-ease),
3034
- color var(--pl-motion-fast) var(--pl-motion-ease);
3035
- }
3036
- .pl-toolcard__copy:hover {
3037
- color: var(--pl-color-fg);
3038
- background: var(--pl-color-bg-hover);
3039
3008
  }
3040
3009
 
3041
3010
  /* ── ui component: theming.css ─────────────────────────────────────────────── */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protolabsai/ui",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -0,0 +1,41 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { EditableText } from "./forms";
4
+
5
+ const meta: Meta = { title: "Components/Forms/EditableText" };
6
+ export default meta;
7
+ type Story = StoryObj;
8
+
9
+ /** Uncontrolled: double-click (or focus + Enter) to edit; Enter commits, Esc cancels.
10
+ * The input is `font: inherit`, so it flips in place seamlessly. */
11
+ export const Inline: Story = {
12
+ render: () => {
13
+ function Demo() {
14
+ const [name, setName] = useState("untitled-agent");
15
+ return (
16
+ <div style={{ fontSize: 16, fontWeight: 600, color: "var(--pl-color-fg)" }}>
17
+ <EditableText value={name} onCommit={setName} aria-label="Agent name" />
18
+ <p style={{ fontFamily: "var(--pl-font-mono)", fontSize: 12, color: "var(--pl-color-fg-muted)", fontWeight: 400 }}>
19
+ value: {name} · double-click to rename
20
+ </p>
21
+ </div>
22
+ );
23
+ }
24
+ return <Demo />;
25
+ },
26
+ };
27
+
28
+ /** With inline validation — slugs only; commit is blocked while invalid (red border). */
29
+ export const Validated: Story = {
30
+ render: () => {
31
+ function Demo() {
32
+ const [slug, setSlug] = useState("my-project");
33
+ return (
34
+ <div style={{ fontFamily: "var(--pl-font-mono)", fontSize: 14, color: "var(--pl-color-fg)" }}>
35
+ <EditableText value={slug} onCommit={setSlug} validate={(v) => /^[a-z0-9-]+$/.test(v)} aria-label="Slug" />
36
+ </div>
37
+ );
38
+ }
39
+ return <Demo />;
40
+ },
41
+ };
@@ -21,7 +21,26 @@ export const States: Story = {
21
21
  render: () => (
22
22
  <div style={{ maxWidth: 460 }}>
23
23
  <ToolCardList>
24
- <ToolCard name="web_fetch" status="done" icon={<Globe />} duration={1240} defaultOpen>
24
+ <ToolCard
25
+ name="web_fetch"
26
+ status="done"
27
+ icon={<Globe />}
28
+ duration={1240}
29
+ defaultOpen
30
+ actions={
31
+ <button
32
+ type="button"
33
+ className="pl-btn pl-btn--ghost pl-btn--icon pl-btn--sm"
34
+ title="Re-run"
35
+ onClick={(e) => e.stopPropagation()}
36
+ >
37
+ <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
38
+ <path d="M23 4v6h-6M1 20v-6h6" />
39
+ <path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15" />
40
+ </svg>
41
+ </button>
42
+ }
43
+ >
25
44
  <ToolSection label="input" copyText='{"url":"https://protolabs.studio"}'>
26
45
  <Mono>{`{ "url": "https://protolabs.studio" }`}</Mono>
27
46
  </ToolSection>
package/src/app-shell.tsx CHANGED
@@ -50,7 +50,7 @@ function RailItemInner({ item }: { item: RailItem }) {
50
50
  </span>
51
51
  <span className="pl-rail__label">{item.label}</span>
52
52
  {item.badge ? (
53
- <span className="pl-rail__badge">{item.badge > 9 ? "9+" : item.badge}</span>
53
+ <span className="pl-count pl-count--rail">{item.badge > 9 ? "9+" : item.badge}</span>
54
54
  ) : item.dot ? (
55
55
  <span className="pl-rail__dot" aria-label="active" />
56
56
  ) : null}
package/src/forms.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
2
- import { createContext, useContext } from "react";
2
+ import { createContext, useContext, useEffect, useState } from "react";
3
3
  import { cx } from "./internal";
4
4
 
5
5
  /** A labeled input/textarea bound to a string value (form fields, editors). */
@@ -171,3 +171,105 @@ export function Radio({
171
171
  </label>
172
172
  );
173
173
  }
174
+
175
+ /** Inline rename: a text label that flips to an input in place — Enter commits,
176
+ * Esc cancels, blur commits (or cancels via `commitOnBlur={false}`). Uncontrolled
177
+ * by default (double-click / Enter to edit); pass `editing` + `onEditingChange` to
178
+ * drive it from a host (e.g. a paired trigger). The input is `font: inherit`, so the
179
+ * flip is visually seamless. The canonical inline-edit control — TabBar renders this. */
180
+ export function EditableText({
181
+ value,
182
+ onCommit,
183
+ onCancel,
184
+ validate,
185
+ commitOnBlur = true,
186
+ editing: editingProp,
187
+ onEditingChange,
188
+ placeholder,
189
+ className,
190
+ inputClassName,
191
+ "aria-label": ariaLabel,
192
+ }: {
193
+ value: string;
194
+ onCommit: (next: string) => void;
195
+ onCancel?: () => void;
196
+ /** Optional inline validity — a falsy result blocks commit (stays in edit). */
197
+ validate?: (v: string) => boolean;
198
+ /** Blur commits (default) or cancels. */
199
+ commitOnBlur?: boolean;
200
+ /** Controlled edit state. Omit for uncontrolled (double-click to edit). */
201
+ editing?: boolean;
202
+ onEditingChange?: (editing: boolean) => void;
203
+ placeholder?: string;
204
+ className?: string;
205
+ inputClassName?: string;
206
+ "aria-label"?: string;
207
+ }) {
208
+ const [uncontrolled, setUncontrolled] = useState(false);
209
+ const editing = editingProp ?? uncontrolled;
210
+ const setEditing = (v: boolean) => {
211
+ if (editingProp === undefined) setUncontrolled(v);
212
+ onEditingChange?.(v);
213
+ };
214
+ const [draft, setDraft] = useState(value);
215
+ // Reset the draft to the live value each time editing (re)starts.
216
+ useEffect(() => {
217
+ if (editing) setDraft(value);
218
+ // eslint-disable-next-line react-hooks/exhaustive-deps
219
+ }, [editing]);
220
+
221
+ const invalid = validate ? !validate(draft.trim()) : false;
222
+ const commit = () => {
223
+ const next = draft.trim();
224
+ if (validate && !validate(next)) return; // stay in edit while invalid
225
+ if (next && next !== value) onCommit(next);
226
+ setEditing(false);
227
+ };
228
+ const cancel = () => {
229
+ setDraft(value);
230
+ onCancel?.();
231
+ setEditing(false);
232
+ };
233
+
234
+ if (!editing) {
235
+ return (
236
+ <span
237
+ className={cx("pl-editable", className)}
238
+ tabIndex={0}
239
+ role="button"
240
+ aria-label={ariaLabel}
241
+ onDoubleClick={() => setEditing(true)}
242
+ onKeyDown={(e) => {
243
+ if (e.key === "Enter" || e.key === "F2") {
244
+ e.preventDefault();
245
+ setEditing(true);
246
+ }
247
+ }}
248
+ >
249
+ {value || placeholder}
250
+ </span>
251
+ );
252
+ }
253
+ return (
254
+ <input
255
+ className={cx("pl-editable__input", invalid && "pl-editable__input--invalid", inputClassName)}
256
+ autoFocus
257
+ value={draft}
258
+ placeholder={placeholder}
259
+ aria-label={ariaLabel}
260
+ aria-invalid={invalid || undefined}
261
+ onChange={(e) => setDraft(e.target.value)}
262
+ onClick={(e) => e.stopPropagation()}
263
+ onBlur={() => (commitOnBlur ? commit() : cancel())}
264
+ onKeyDown={(e) => {
265
+ if (e.key === "Enter") {
266
+ e.preventDefault();
267
+ commit();
268
+ } else if (e.key === "Escape") {
269
+ e.preventDefault();
270
+ cancel();
271
+ }
272
+ }}
273
+ />
274
+ );
275
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
2
2
  import { useId, useState } from "react";
3
3
  import { cx } from "./internal";
4
+ import { EditableText } from "./forms";
4
5
 
5
6
  export type TabItem = {
6
7
  id: string;
@@ -50,7 +51,7 @@ export function Tabs({
50
51
  </span>
51
52
  )}
52
53
  <span className="pl-tab__label">{t.label}</span>
53
- {t.badge != null && <span className="pl-tab__badge">{t.badge}</span>}
54
+ {t.badge != null && <span className="pl-count">{t.badge}</span>}
54
55
  {t.locked ? (
55
56
  <span className="pl-tab__lock" aria-hidden>
56
57
  🔒
@@ -111,20 +112,8 @@ export function TabBar({
111
112
  addLabel?: string;
112
113
  ariaLabel?: string;
113
114
  }) {
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
- };
115
+ // Which tab is mid-rename; the inline editor itself is the shared EditableText.
116
+ const [editingId, setEditingId] = useState<string | null>(null);
128
117
  return (
129
118
  <div className="pl-tabbar" role="tablist" aria-label={ariaLabel}>
130
119
  {items.map((t) => (
@@ -134,10 +123,10 @@ export function TabBar({
134
123
  tabIndex={0}
135
124
  aria-selected={t.id === activeId}
136
125
  className={cx("pl-tabbar__tab", t.id === activeId && "pl-tabbar__tab--active")}
137
- onClick={() => editing !== t.id && onSelect(t.id)}
138
- onDoubleClick={() => startEdit(t)}
126
+ onClick={() => editingId !== t.id && onSelect(t.id)}
127
+ onDoubleClick={() => onRename && setEditingId(t.id)}
139
128
  onKeyDown={(e) => {
140
- if (editing === t.id) return;
129
+ if (editingId === t.id) return;
141
130
  if (e.key === "Enter" || e.key === " ") {
142
131
  e.preventDefault();
143
132
  onSelect(t.id);
@@ -149,32 +138,24 @@ export function TabBar({
149
138
  {t.icon}
150
139
  </span>
151
140
  )}
152
- {editing === t.id ? (
153
- <input
154
- className="pl-tabbar__edit"
155
- autoFocus
156
- value={draft}
141
+ {editingId === t.id ? (
142
+ <EditableText
143
+ editing
144
+ value={t.label}
157
145
  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
- }
146
+ onCommit={(v) => onRename?.(t.id, v)}
147
+ onEditingChange={(e) => {
148
+ if (!e) setEditingId(null);
168
149
  }}
169
150
  />
170
151
  ) : (
171
152
  <span className="pl-tabbar__label">{t.label}</span>
172
153
  )}
173
- {t.badge != null && <span className="pl-tabbar__badge">{t.badge}</span>}
174
- {onClose && editing !== t.id && (
154
+ {t.badge != null && <span className="pl-count">{t.badge}</span>}
155
+ {onClose && editingId !== t.id && (
175
156
  <button
176
157
  type="button"
177
- className="pl-tabbar__close"
158
+ className="pl-iconbtn pl-tabbar__close"
178
159
  aria-label={`Close ${t.label}`}
179
160
  onClick={(e) => {
180
161
  e.stopPropagation();
@@ -189,7 +170,7 @@ export function TabBar({
189
170
  </div>
190
171
  ))}
191
172
  {onAdd && (
192
- <button type="button" className="pl-tabbar__add" aria-label={addLabel} title={addLabel} onClick={onAdd}>
173
+ <button type="button" className="pl-iconbtn pl-tabbar__add" aria-label={addLabel} title={addLabel} onClick={onAdd}>
193
174
  <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
194
175
  <path d="M12 5v14M5 12h14" />
195
176
  </svg>
package/src/overlays.tsx CHANGED
@@ -100,7 +100,7 @@ export function Dialog({
100
100
  {title}
101
101
  </div>
102
102
  {onClose && (
103
- <button type="button" className="pl-dialog__close" aria-label="Close" onClick={onClose}>
103
+ <button type="button" className="pl-iconbtn pl-dialog__close" aria-label="Close" onClick={onClose}>
104
104
  ×
105
105
  </button>
106
106
  )}
@@ -200,7 +200,7 @@ export function Drawer({
200
200
  {title}
201
201
  </div>
202
202
  {onClose && (
203
- <button type="button" className="pl-dialog__close" aria-label="Close" onClick={onClose}>
203
+ <button type="button" className="pl-iconbtn pl-dialog__close" aria-label="Close" onClick={onClose}>
204
204
  ×
205
205
  </button>
206
206
  )}
@@ -265,7 +265,7 @@ function ToastView({ toast, onDismiss }: { toast: ToastItem; onDismiss: (id: str
265
265
  {toast.title != null && <div className="pl-toast__title">{toast.title}</div>}
266
266
  <div className="pl-toast__msg">{toast.message}</div>
267
267
  </div>
268
- <button type="button" className="pl-toast__close" aria-label="Dismiss" onClick={() => onDismiss(toast.id)}>
268
+ <button type="button" className="pl-iconbtn pl-toast__close" aria-label="Dismiss" onClick={() => onDismiss(toast.id)}>
269
269
  ×
270
270
  </button>
271
271
  </div>
@@ -65,22 +65,17 @@
65
65
  white-space: nowrap;
66
66
  }
67
67
 
68
- .pl-rail__badge {
68
+ /* rail count badge = shared .pl-count (primitives.css) + absolute positioning */
69
+ .pl-count--rail {
69
70
  position: absolute;
70
71
  top: 4px;
71
72
  right: 9px;
72
- display: inline-flex;
73
- align-items: center;
74
- justify-content: center;
75
73
  min-width: 15px;
76
74
  height: 15px;
77
75
  padding: 0 4px;
78
- font-family: var(--pl-font-mono);
79
76
  font-size: 9px;
80
77
  color: var(--pl-color-fg);
81
- background: var(--pl-color-bg-subtle);
82
- border: var(--pl-border-width) solid var(--pl-color-border-strong);
83
- border-radius: 999px;
78
+ border-color: var(--pl-color-border-strong);
84
79
  }
85
80
 
86
81
  .pl-rail__dot {
@@ -252,3 +252,30 @@
252
252
  font-size: 13px;
253
253
  color: var(--pl-color-fg);
254
254
  }
255
+
256
+ /* ── EditableText (inline rename) ── */
257
+ /* Both states are font:inherit so the label↔input flip is seamless in any context. */
258
+ .pl-editable {
259
+ cursor: text;
260
+ border-radius: calc(var(--pl-radius) - 2px);
261
+ }
262
+ .pl-editable:hover {
263
+ background: var(--pl-color-bg-hover);
264
+ }
265
+ .pl-editable:focus-visible {
266
+ outline: 2px solid var(--pl-color-accent);
267
+ outline-offset: 2px;
268
+ }
269
+ .pl-editable__input {
270
+ min-width: 60px;
271
+ padding: 1px 4px;
272
+ font: inherit;
273
+ color: var(--pl-color-fg);
274
+ background: var(--pl-color-bg-inset);
275
+ border: var(--pl-border-width) solid var(--pl-color-accent);
276
+ border-radius: calc(var(--pl-radius) - 2px);
277
+ outline: none;
278
+ }
279
+ .pl-editable__input--invalid {
280
+ border-color: var(--pl-color-status-error);
281
+ }
@@ -32,23 +32,8 @@
32
32
  height: 15px;
33
33
  }
34
34
 
35
- .pl-tab__badge {
36
- display: inline-flex;
37
- align-items: center;
38
- justify-content: center;
39
- min-width: 16px;
40
- height: 16px;
41
- padding: 0 5px;
42
- font-family: var(--pl-font-mono);
43
- font-size: 10px;
44
- line-height: 1;
45
- color: var(--pl-color-fg-muted);
46
- background: var(--pl-color-bg-subtle);
47
- border: var(--pl-border-width) solid var(--pl-color-border);
48
- border-radius: 999px;
49
- }
50
-
51
- .pl-tab--active .pl-tab__badge {
35
+ /* count badge is the shared .pl-count (primitives.css) */
36
+ .pl-tab--active .pl-count {
52
37
  color: var(--pl-color-fg);
53
38
  }
54
39
 
@@ -315,74 +300,22 @@
315
300
  text-overflow: ellipsis;
316
301
  white-space: nowrap;
317
302
  }
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
- }
303
+ /* inline rename uses the shared EditableText control (.pl-editable__input) */
304
+ /* count badge is the shared .pl-count (primitives.css) */
305
+ /* close / add extend the shared .pl-iconbtn (primitives.css) — deltas only */
345
306
  .pl-tabbar__close {
346
- display: inline-flex;
347
- align-items: center;
348
- justify-content: center;
349
307
  width: 18px;
350
308
  height: 18px;
351
309
  margin-right: -3px;
352
- padding: 0;
353
310
  color: var(--pl-color-fg-subtle);
354
- background: none;
355
- border: none;
356
- border-radius: var(--pl-radius);
357
- cursor: pointer;
358
311
  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);
312
+ transition: opacity var(--pl-motion-fast) var(--pl-motion-ease);
363
313
  }
364
314
  .pl-tabbar__close:hover {
365
- color: var(--pl-color-fg);
366
- background: var(--pl-color-bg-hover);
367
315
  opacity: 1;
368
316
  }
369
317
  .pl-tabbar__add {
370
- display: inline-flex;
371
- align-items: center;
372
- justify-content: center;
373
318
  width: 28px;
374
319
  flex-shrink: 0;
375
320
  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
321
  }
@@ -48,28 +48,14 @@
48
48
  color: var(--pl-color-fg);
49
49
  }
50
50
 
51
+ /* extends the shared .pl-iconbtn (primitives.css) — delta only */
51
52
  .pl-dialog__close {
52
- display: inline-flex;
53
- align-items: center;
54
- justify-content: center;
55
53
  width: 24px;
56
54
  height: 24px;
57
55
  font-size: 18px;
58
56
  line-height: 1;
59
- color: var(--pl-color-fg-muted);
60
- background: transparent;
61
- border: none;
62
- border-radius: var(--pl-radius);
63
- cursor: pointer;
64
- transition:
65
- color var(--pl-motion-fast) var(--pl-motion-ease),
66
- background var(--pl-motion-fast) var(--pl-motion-ease);
67
57
  }
68
58
 
69
- .pl-dialog__close:hover {
70
- color: var(--pl-color-fg);
71
- background: var(--pl-color-bg-hover);
72
- }
73
59
 
74
60
  .pl-dialog__body {
75
61
  padding: var(--pl-space-4);
@@ -228,19 +214,13 @@
228
214
  color: var(--pl-color-fg-muted);
229
215
  }
230
216
 
217
+ /* extends the shared .pl-iconbtn (primitives.css) — delta only */
231
218
  .pl-toast__close {
232
219
  flex-shrink: 0;
233
220
  font-size: 16px;
234
221
  line-height: 1;
235
- color: var(--pl-color-fg-muted);
236
- background: transparent;
237
- border: none;
238
- cursor: pointer;
239
222
  }
240
223
 
241
- .pl-toast__close:hover {
242
- color: var(--pl-color-fg);
243
- }
244
224
 
245
225
  /* ── Tooltip (CSS-only) ──────────────────────────────────────────────────────── */
246
226
  .pl-tip-wrap {
@@ -135,6 +135,45 @@
135
135
  margin-top: var(--pl-space-2);
136
136
  }
137
137
 
138
+ /* ── count badge (round numeric pill) — shared by Tabs / TabBar / SurfaceRail.
139
+ Distinct from .pl-badge (a lowercase text/label pill). ── */
140
+ .pl-count {
141
+ display: inline-flex;
142
+ align-items: center;
143
+ justify-content: center;
144
+ min-width: 16px;
145
+ height: 16px;
146
+ padding: 0 5px;
147
+ font-family: var(--pl-font-mono);
148
+ font-size: 10px;
149
+ line-height: 1;
150
+ color: var(--pl-color-fg-muted);
151
+ background: var(--pl-color-bg-subtle);
152
+ border: var(--pl-border-width) solid var(--pl-color-border);
153
+ border-radius: 999px;
154
+ }
155
+
156
+ /* ── icon button (bare clickable glyph) — shared base for close / add / copy /
157
+ dialog + toast dismiss. Each use adds its own size/margin delta. ── */
158
+ .pl-iconbtn {
159
+ display: inline-flex;
160
+ align-items: center;
161
+ justify-content: center;
162
+ padding: 0;
163
+ color: var(--pl-color-fg-muted);
164
+ background: none;
165
+ border: none;
166
+ border-radius: var(--pl-radius);
167
+ cursor: pointer;
168
+ transition:
169
+ background var(--pl-motion-fast) var(--pl-motion-ease),
170
+ color var(--pl-motion-fast) var(--pl-motion-ease);
171
+ }
172
+ .pl-iconbtn:hover {
173
+ color: var(--pl-color-fg);
174
+ background: var(--pl-color-bg-hover);
175
+ }
176
+
138
177
  /* ── divider ── */
139
178
  .pl-divider {
140
179
  border: 0;
@@ -24,11 +24,16 @@
24
24
  overflow: hidden;
25
25
  }
26
26
 
27
+ .pl-toolcard__head-row {
28
+ display: flex;
29
+ align-items: center;
30
+ }
27
31
  .pl-toolcard__head {
32
+ flex: 1 1 auto;
33
+ min-width: 0;
28
34
  display: flex;
29
35
  align-items: center;
30
36
  gap: 7px;
31
- width: 100%;
32
37
  padding: 6px 9px;
33
38
  background: none;
34
39
  border: none;
@@ -38,6 +43,14 @@
38
43
  text-align: left;
39
44
  cursor: pointer;
40
45
  }
46
+ .pl-toolcard__actions {
47
+ flex: none;
48
+ display: inline-flex;
49
+ align-items: center;
50
+ gap: 2px;
51
+ padding-right: 6px;
52
+ color: var(--pl-color-fg-muted);
53
+ }
41
54
  .pl-toolcard__head:disabled {
42
55
  cursor: default;
43
56
  }
@@ -99,13 +112,9 @@
99
112
  .pl-toolcard__status--running {
100
113
  color: var(--pl-color-status-warning);
101
114
  }
115
+ /* reuses the shared `pl-spin` keyframe (data.css) */
102
116
  .pl-toolcard__spin {
103
- animation: pl-toolcard-spin 0.8s linear infinite;
104
- }
105
- @keyframes pl-toolcard-spin {
106
- to {
107
- transform: rotate(360deg);
108
- }
117
+ animation: pl-spin 0.8s linear infinite;
109
118
  }
110
119
 
111
120
  .pl-toolcard__body {
@@ -147,21 +156,7 @@
147
156
  letter-spacing: 0.04em;
148
157
  color: var(--pl-color-fg-subtle);
149
158
  }
159
+ /* extends the shared .pl-iconbtn (primitives.css) — delta only */
150
160
  .pl-toolcard__copy {
151
- display: inline-flex;
152
- align-items: center;
153
- justify-content: center;
154
161
  padding: 2px;
155
- background: none;
156
- border: none;
157
- border-radius: var(--pl-radius);
158
- color: var(--pl-color-fg-muted);
159
- cursor: pointer;
160
- transition:
161
- background var(--pl-motion-fast) var(--pl-motion-ease),
162
- color var(--pl-motion-fast) var(--pl-motion-ease);
163
- }
164
- .pl-toolcard__copy:hover {
165
- color: var(--pl-color-fg);
166
- background: var(--pl-color-bg-hover);
167
162
  }
package/src/tool-card.tsx CHANGED
@@ -61,6 +61,7 @@ export function ToolCard({
61
61
  duration,
62
62
  defaultOpen = false,
63
63
  nested,
64
+ actions,
64
65
  children,
65
66
  className,
66
67
  }: {
@@ -73,6 +74,9 @@ export function ToolCard({
73
74
  defaultOpen?: boolean;
74
75
  /** Indented child tool cards for a subagent `task`. */
75
76
  nested?: ReactNode;
77
+ /** Trailing header slot — hang a re-run / per-tool affordance here. Sits outside
78
+ * the disclosure toggle (so it can hold its own buttons). */
79
+ actions?: ReactNode;
76
80
  /** Expanded body — the rendered input/result (compose `ToolSection`s). */
77
81
  children?: ReactNode;
78
82
  className?: string;
@@ -81,34 +85,37 @@ export function ToolCard({
81
85
  const hasBody = children != null;
82
86
  const card = (
83
87
  <div className={cx("pl-toolcard", `pl-toolcard--${status}`, className)}>
84
- <button
85
- type="button"
86
- className="pl-toolcard__head"
87
- aria-expanded={hasBody ? open : undefined}
88
- disabled={!hasBody}
89
- onClick={() => setOpen((v) => !v)}
90
- >
91
- <span
92
- className={cx(
93
- "pl-toolcard__caret",
94
- !hasBody && "pl-toolcard__caret--hidden",
95
- open && "pl-toolcard__caret--open",
96
- )}
97
- aria-hidden
88
+ <div className="pl-toolcard__head-row">
89
+ <button
90
+ type="button"
91
+ className="pl-toolcard__head"
92
+ aria-expanded={hasBody ? open : undefined}
93
+ disabled={!hasBody}
94
+ onClick={() => setOpen((v) => !v)}
98
95
  >
99
- <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
100
- <path d="M9 6l6 6-6 6" />
101
- </svg>
102
- </span>
103
- {icon != null && (
104
- <span className="pl-toolcard__icon" aria-hidden>
105
- {icon}
96
+ <span
97
+ className={cx(
98
+ "pl-toolcard__caret",
99
+ !hasBody && "pl-toolcard__caret--hidden",
100
+ open && "pl-toolcard__caret--open",
101
+ )}
102
+ aria-hidden
103
+ >
104
+ <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
105
+ <path d="M9 6l6 6-6 6" />
106
+ </svg>
106
107
  </span>
107
- )}
108
- <span className="pl-toolcard__name">{name}</span>
109
- {duration != null && <span className="pl-toolcard__dur">{formatDuration(duration)}</span>}
110
- <StatusGlyph status={status} />
111
- </button>
108
+ {icon != null && (
109
+ <span className="pl-toolcard__icon" aria-hidden>
110
+ {icon}
111
+ </span>
112
+ )}
113
+ <span className="pl-toolcard__name">{name}</span>
114
+ {duration != null && <span className="pl-toolcard__dur">{formatDuration(duration)}</span>}
115
+ <StatusGlyph status={status} />
116
+ </button>
117
+ {actions != null && <div className="pl-toolcard__actions">{actions}</div>}
118
+ </div>
112
119
  {hasBody && open && <div className="pl-toolcard__body">{children}</div>}
113
120
  </div>
114
121
  );
@@ -126,7 +133,7 @@ function CopyButton({ text }: { text: string }) {
126
133
  return (
127
134
  <button
128
135
  type="button"
129
- className="pl-toolcard__copy"
136
+ className="pl-iconbtn pl-toolcard__copy"
130
137
  title="Copy to clipboard"
131
138
  aria-label={copied ? "Copied" : "Copy"}
132
139
  onClick={async () => {